feat: add SQL query tool, platform debug, loyalty settings, and multi-module improvements
Some checks failed
Some checks failed
- Add admin SQL query tool with saved queries, schema explorer presets, and collapsible category sections (dev_tools module) - Add platform debug tool for admin diagnostics - Add loyalty settings page with owner-only access control - Fix loyalty settings owner check (use currentUser instead of window.__userData) - Replace HTTPException with AuthorizationException in loyalty routes - Expand loyalty module with PIN service, Apple Wallet, program management - Improve store login with platform detection and multi-platform support - Update billing feature gates and subscription services - Add store platform sync improvements and remove is_primary column - Add unit tests for loyalty (PIN, points, stamps, program services) - Update i18n translations across dev_tools locales Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -129,9 +129,9 @@ loyalty_module = ModuleDefinition(
|
||||
"loyalty-analytics", # Platform-wide stats
|
||||
],
|
||||
FrontendType.STORE: [
|
||||
"loyalty", # Loyalty dashboard
|
||||
"loyalty-cards", # Customer cards
|
||||
"loyalty-stats", # Store stats
|
||||
"terminal", # Loyalty terminal
|
||||
"cards", # Customer cards
|
||||
"stats", # Store stats
|
||||
],
|
||||
FrontendType.MERCHANT: [
|
||||
"loyalty-overview", # Merchant loyalty overview
|
||||
|
||||
@@ -6,6 +6,8 @@ Admin-controlled settings that apply to a merchant's loyalty program.
|
||||
These settings are managed by platform administrators, not stores.
|
||||
"""
|
||||
|
||||
import enum
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
@@ -20,7 +22,7 @@ from app.core.database import Base
|
||||
from models.database.base import TimestampMixin
|
||||
|
||||
|
||||
class StaffPinPolicy(str):
|
||||
class StaffPinPolicy(str, enum.Enum):
|
||||
"""Staff PIN policy options."""
|
||||
|
||||
REQUIRED = "required" # Staff PIN always required
|
||||
|
||||
@@ -11,6 +11,7 @@ Platform endpoints for:
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, Path, Response
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
@@ -92,8 +93,14 @@ def download_apple_pass(
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class AppleRegisterDeviceRequest(BaseModel):
|
||||
"""Request body for Apple device registration."""
|
||||
push_token: str = Field(..., alias="pushToken")
|
||||
|
||||
|
||||
@platform_router.post("/apple/v1/devices/{device_id}/registrations/{pass_type_id}/{serial_number}")
|
||||
def register_device(
|
||||
body: AppleRegisterDeviceRequest,
|
||||
device_id: str = Path(...),
|
||||
pass_type_id: str = Path(...),
|
||||
serial_number: str = Path(...),
|
||||
@@ -111,10 +118,7 @@ def register_device(
|
||||
# Verify auth token (raises InvalidAppleAuthTokenException if invalid)
|
||||
apple_wallet_service.verify_auth_token(card, authorization)
|
||||
|
||||
# Get push token from request body
|
||||
# Note: In real implementation, parse the JSON body for pushToken
|
||||
# For now, use device_id as a placeholder
|
||||
apple_wallet_service.register_device_safe(db, card, device_id, device_id)
|
||||
apple_wallet_service.register_device_safe(db, card, device_id, body.pushToken)
|
||||
return Response(status_code=201)
|
||||
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_store_api, require_module_access
|
||||
from app.core.database import get_db
|
||||
from app.exceptions.base import AuthorizationException
|
||||
from app.modules.enums import FrontendType
|
||||
from app.modules.loyalty.schemas import (
|
||||
CardDetailResponse,
|
||||
@@ -40,8 +41,10 @@ from app.modules.loyalty.schemas import (
|
||||
PointsRedeemResponse,
|
||||
PointsVoidRequest,
|
||||
PointsVoidResponse,
|
||||
ProgramCreate,
|
||||
ProgramResponse,
|
||||
ProgramStatsResponse,
|
||||
ProgramUpdate,
|
||||
StampRedeemRequest,
|
||||
StampRedeemResponse,
|
||||
StampRequest,
|
||||
@@ -104,6 +107,52 @@ def get_program(
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/program", response_model=ProgramResponse, status_code=201)
|
||||
def create_program(
|
||||
data: ProgramCreate,
|
||||
current_user: User = Depends(get_current_store_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Create a loyalty program (merchant_owner only)."""
|
||||
if current_user.role != "merchant_owner":
|
||||
raise AuthorizationException("Only merchant owners can create programs")
|
||||
|
||||
store_id = current_user.token_store_id
|
||||
merchant_id = get_store_merchant_id(db, store_id)
|
||||
|
||||
program = program_service.create_program(db, merchant_id, data)
|
||||
|
||||
response = ProgramResponse.model_validate(program)
|
||||
response.is_stamps_enabled = program.is_stamps_enabled
|
||||
response.is_points_enabled = program.is_points_enabled
|
||||
response.display_name = program.display_name
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.put("/program", response_model=ProgramResponse)
|
||||
def update_program(
|
||||
data: ProgramUpdate,
|
||||
current_user: User = Depends(get_current_store_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update the merchant's loyalty program (merchant_owner only)."""
|
||||
if current_user.role != "merchant_owner":
|
||||
raise AuthorizationException("Only merchant owners can update programs")
|
||||
|
||||
store_id = current_user.token_store_id
|
||||
|
||||
program = program_service.require_program_by_store(db, store_id)
|
||||
program = program_service.update_program(db, program.id, data)
|
||||
|
||||
response = ProgramResponse.model_validate(program)
|
||||
response.is_stamps_enabled = program.is_stamps_enabled
|
||||
response.is_points_enabled = program.is_points_enabled
|
||||
response.display_name = program.display_name
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/stats", response_model=ProgramStatsResponse)
|
||||
def get_stats(
|
||||
current_user: User = Depends(get_current_store_api),
|
||||
|
||||
@@ -208,6 +208,32 @@ async def store_loyalty_stats(
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SETTINGS (Merchant Owner)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get(
|
||||
"/loyalty/settings",
|
||||
response_class=HTMLResponse,
|
||||
include_in_schema=False,
|
||||
)
|
||||
async def store_loyalty_settings(
|
||||
request: Request,
|
||||
store_code: str = Depends(get_resolved_store_code),
|
||||
current_user: User = Depends(get_current_store_from_cookie_or_header),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Render loyalty program settings page.
|
||||
Allows merchant owners to create or edit their loyalty program.
|
||||
"""
|
||||
return templates.TemplateResponse(
|
||||
"loyalty/store/settings.html",
|
||||
get_store_context(request, db, current_user, store_code),
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ENROLLMENT
|
||||
# ============================================================================
|
||||
|
||||
@@ -240,6 +240,7 @@ class ProgramStatsResponse(BaseModel):
|
||||
# Cards
|
||||
total_cards: int = 0
|
||||
active_cards: int = 0
|
||||
new_this_month: int = 0
|
||||
|
||||
# Stamps (if enabled)
|
||||
total_stamps_issued: int = 0
|
||||
@@ -250,6 +251,7 @@ class ProgramStatsResponse(BaseModel):
|
||||
# Points (if enabled)
|
||||
total_points_issued: int = 0
|
||||
total_points_redeemed: int = 0
|
||||
total_points_balance: int = 0
|
||||
points_this_month: int = 0
|
||||
points_redeemed_this_month: int = 0
|
||||
|
||||
@@ -257,6 +259,12 @@ class ProgramStatsResponse(BaseModel):
|
||||
cards_with_activity_30d: int = 0
|
||||
average_stamps_per_card: float = 0.0
|
||||
average_points_per_card: float = 0.0
|
||||
avg_points_per_member: float = 0.0
|
||||
|
||||
# 30-day metrics
|
||||
transactions_30d: int = 0
|
||||
points_issued_30d: int = 0
|
||||
points_redeemed_30d: int = 0
|
||||
|
||||
# Value
|
||||
estimated_liability_cents: int = 0 # Unredeemed stamps/points value
|
||||
|
||||
@@ -234,12 +234,11 @@ class AppleWalletService:
|
||||
"pass.json": json.dumps(pass_data).encode("utf-8"),
|
||||
}
|
||||
|
||||
# Add placeholder images (in production, these would be actual images)
|
||||
# For now, we'll skip images and use the pass.json only
|
||||
# pass_files["icon.png"] = self._get_icon_bytes(program)
|
||||
# pass_files["icon@2x.png"] = self._get_icon_bytes(program, scale=2)
|
||||
# pass_files["logo.png"] = self._get_logo_bytes(program)
|
||||
# pass_files["logo@2x.png"] = self._get_logo_bytes(program, scale=2)
|
||||
# Add pass images (icon and logo)
|
||||
pass_files["icon.png"] = self._get_icon_bytes(program)
|
||||
pass_files["icon@2x.png"] = self._get_icon_bytes(program, scale=2)
|
||||
pass_files["logo.png"] = self._get_logo_bytes(program)
|
||||
pass_files["logo@2x.png"] = self._get_logo_bytes(program, scale=2)
|
||||
|
||||
# Create manifest
|
||||
manifest = {}
|
||||
@@ -377,6 +376,105 @@ class AppleWalletService:
|
||||
|
||||
return pass_data
|
||||
|
||||
def _get_icon_bytes(self, program: LoyaltyProgram, scale: int = 1) -> bytes:
|
||||
"""
|
||||
Generate icon image for Apple Wallet pass.
|
||||
|
||||
Apple icon dimensions: 29x29 (@1x), 58x58 (@2x).
|
||||
Uses program logo if available, otherwise generates a colored square
|
||||
with the program initial.
|
||||
"""
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
size = 29 * scale
|
||||
|
||||
if program.logo_url:
|
||||
try:
|
||||
import httpx
|
||||
|
||||
resp = httpx.get(program.logo_url, timeout=10, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
img = Image.open(io.BytesIO(resp.content))
|
||||
img = img.convert("RGBA")
|
||||
img = img.resize((size, size), Image.LANCZOS)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
except Exception:
|
||||
logger.warning("Failed to fetch logo for icon, using fallback")
|
||||
|
||||
# Fallback: colored square with initial
|
||||
hex_color = (program.card_color or "#4F46E5").lstrip("#")
|
||||
r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
|
||||
|
||||
img = Image.new("RGBA", (size, size), (r, g, b, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
initial = (program.display_name or "L")[0].upper()
|
||||
try:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", size // 2)
|
||||
except OSError:
|
||||
font = ImageFont.load_default()
|
||||
bbox = draw.textbbox((0, 0), initial, font=font)
|
||||
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||
draw.text(((size - tw) / 2, (size - th) / 2 - bbox[1]), initial, fill=(255, 255, 255, 255), font=font)
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
def _get_logo_bytes(self, program: LoyaltyProgram, scale: int = 1) -> bytes:
|
||||
"""
|
||||
Generate logo image for Apple Wallet pass.
|
||||
|
||||
Apple logo dimensions: 160x50 (@1x), 320x100 (@2x).
|
||||
Uses program logo if available, otherwise generates a colored rectangle
|
||||
with the program initial.
|
||||
"""
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
width, height = 160 * scale, 50 * scale
|
||||
|
||||
if program.logo_url:
|
||||
try:
|
||||
import httpx
|
||||
|
||||
resp = httpx.get(program.logo_url, timeout=10, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
img = Image.open(io.BytesIO(resp.content))
|
||||
img = img.convert("RGBA")
|
||||
# Fit within dimensions preserving aspect ratio
|
||||
img.thumbnail((width, height), Image.LANCZOS)
|
||||
# Center on transparent canvas
|
||||
canvas = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
||||
x = (width - img.width) // 2
|
||||
y = (height - img.height) // 2
|
||||
canvas.paste(img, (x, y))
|
||||
buf = io.BytesIO()
|
||||
canvas.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
except Exception:
|
||||
logger.warning("Failed to fetch logo for pass logo, using fallback")
|
||||
|
||||
# Fallback: colored rectangle with initial
|
||||
hex_color = (program.card_color or "#4F46E5").lstrip("#")
|
||||
r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
|
||||
|
||||
img = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
initial = (program.display_name or "L")[0].upper()
|
||||
try:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", height // 2)
|
||||
except OSError:
|
||||
font = ImageFont.load_default()
|
||||
bbox = draw.textbbox((0, 0), initial, font=font)
|
||||
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||
# Draw initial centered
|
||||
draw.text(((width - tw) / 2, (height - th) / 2 - bbox[1]), initial, fill=(r, g, b, 255), font=font)
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
def _hex_to_rgb(self, hex_color: str) -> str:
|
||||
"""Convert hex color to RGB format for Apple Wallet."""
|
||||
hex_color = hex_color.lstrip("#")
|
||||
|
||||
@@ -164,7 +164,34 @@ class LoyaltyFeatureProvider:
|
||||
db: Session,
|
||||
store_id: int,
|
||||
) -> list[FeatureUsage]:
|
||||
return []
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.modules.loyalty.models import LoyaltyCard, StaffPin
|
||||
|
||||
active_cards = (
|
||||
db.query(func.count(LoyaltyCard.id))
|
||||
.filter(
|
||||
LoyaltyCard.enrolled_at_store_id == store_id,
|
||||
LoyaltyCard.is_active == True,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
active_pins = (
|
||||
db.query(func.count(StaffPin.id))
|
||||
.filter(
|
||||
StaffPin.store_id == store_id,
|
||||
StaffPin.is_active == True,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
return [
|
||||
FeatureUsage(feature_code="loyalty_cards", current_usage=active_cards),
|
||||
FeatureUsage(feature_code="loyalty_staff_pins", current_usage=active_pins),
|
||||
]
|
||||
|
||||
def get_merchant_usage(
|
||||
self,
|
||||
@@ -172,7 +199,58 @@ class LoyaltyFeatureProvider:
|
||||
merchant_id: int,
|
||||
platform_id: int,
|
||||
) -> list[FeatureUsage]:
|
||||
return []
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.modules.loyalty.models import (
|
||||
AppleDeviceRegistration,
|
||||
LoyaltyCard,
|
||||
StaffPin,
|
||||
)
|
||||
|
||||
active_cards = (
|
||||
db.query(func.count(LoyaltyCard.id))
|
||||
.filter(
|
||||
LoyaltyCard.merchant_id == merchant_id,
|
||||
LoyaltyCard.is_active == True,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
active_pins = (
|
||||
db.query(func.count(StaffPin.id))
|
||||
.filter(
|
||||
StaffPin.merchant_id == merchant_id,
|
||||
StaffPin.is_active == True,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
apple_registrations = (
|
||||
db.query(func.count(AppleDeviceRegistration.id))
|
||||
.join(LoyaltyCard)
|
||||
.filter(LoyaltyCard.merchant_id == merchant_id)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
google_wallets = (
|
||||
db.query(func.count(LoyaltyCard.id))
|
||||
.filter(
|
||||
LoyaltyCard.merchant_id == merchant_id,
|
||||
LoyaltyCard.google_object_id.isnot(None),
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
return [
|
||||
FeatureUsage(feature_code="loyalty_cards", current_usage=active_cards),
|
||||
FeatureUsage(feature_code="loyalty_staff_pins", current_usage=active_pins),
|
||||
FeatureUsage(feature_code="loyalty_apple_wallet", current_usage=apple_registrations),
|
||||
FeatureUsage(feature_code="loyalty_google_wallet", current_usage=google_wallets),
|
||||
]
|
||||
|
||||
|
||||
# Singleton instance for module registration
|
||||
|
||||
@@ -29,7 +29,7 @@ class LoyaltyOnboardingProvider:
|
||||
title_key="loyalty.onboarding.create_program.title",
|
||||
description_key="loyalty.onboarding.create_program.description",
|
||||
icon="gift",
|
||||
route_template="/store/{store_code}/loyalty/programs",
|
||||
route_template="/store/{store_code}/loyalty/settings",
|
||||
order=300,
|
||||
category="loyalty",
|
||||
),
|
||||
@@ -49,13 +49,13 @@ class LoyaltyOnboardingProvider:
|
||||
if not store:
|
||||
return False
|
||||
|
||||
count = (
|
||||
exists = (
|
||||
db.query(LoyaltyProgram)
|
||||
.filter(LoyaltyProgram.merchant_id == store.merchant_id)
|
||||
.limit(1)
|
||||
.count()
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
return count > 0
|
||||
return exists
|
||||
|
||||
|
||||
loyalty_onboarding_provider = LoyaltyOnboardingProvider()
|
||||
|
||||
@@ -291,9 +291,8 @@ class PinService:
|
||||
|
||||
return pin
|
||||
|
||||
# No match found - record failed attempt on all unlocked PINs
|
||||
# This is a simplified approach; in production you might want to
|
||||
# track which PIN was attempted based on additional context
|
||||
# No match found - record failed attempt on the first unlocked PIN only
|
||||
# This limits blast radius to 1 lockout instead of N
|
||||
locked_pin = None
|
||||
remaining = None
|
||||
|
||||
@@ -306,7 +305,8 @@ class PinService:
|
||||
if is_now_locked:
|
||||
locked_pin = pin
|
||||
else:
|
||||
remaining = pin.remaining_attempts
|
||||
remaining = max(0, config.pin_max_failed_attempts - pin.failed_attempts)
|
||||
break # Only record on the first unlocked PIN
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -663,6 +663,78 @@ class ProgramService:
|
||||
avg_stamps = total_stamps_issued / total_cards if total_cards > 0 else 0
|
||||
avg_points = total_points_issued / total_cards if total_cards > 0 else 0
|
||||
|
||||
# New this month (cards created since month start)
|
||||
new_this_month = (
|
||||
db.query(func.count(LoyaltyCard.id))
|
||||
.filter(
|
||||
LoyaltyCard.program_id == program_id,
|
||||
LoyaltyCard.created_at >= month_start,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Points activity this month
|
||||
points_this_month = (
|
||||
db.query(func.sum(LoyaltyTransaction.points_delta))
|
||||
.join(LoyaltyCard)
|
||||
.filter(
|
||||
LoyaltyCard.program_id == program_id,
|
||||
LoyaltyTransaction.points_delta > 0,
|
||||
LoyaltyTransaction.transaction_at >= month_start,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
points_redeemed_this_month = (
|
||||
db.query(func.sum(func.abs(LoyaltyTransaction.points_delta)))
|
||||
.join(LoyaltyCard)
|
||||
.filter(
|
||||
LoyaltyCard.program_id == program_id,
|
||||
LoyaltyTransaction.points_delta < 0,
|
||||
LoyaltyTransaction.transaction_at >= month_start,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# 30-day transaction metrics
|
||||
transactions_30d = (
|
||||
db.query(func.count(LoyaltyTransaction.id))
|
||||
.join(LoyaltyCard)
|
||||
.filter(
|
||||
LoyaltyCard.program_id == program_id,
|
||||
LoyaltyTransaction.transaction_at >= thirty_days_ago,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
points_issued_30d = (
|
||||
db.query(func.sum(LoyaltyTransaction.points_delta))
|
||||
.join(LoyaltyCard)
|
||||
.filter(
|
||||
LoyaltyCard.program_id == program_id,
|
||||
LoyaltyTransaction.points_delta > 0,
|
||||
LoyaltyTransaction.transaction_at >= thirty_days_ago,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
points_redeemed_30d = (
|
||||
db.query(func.sum(func.abs(LoyaltyTransaction.points_delta)))
|
||||
.join(LoyaltyCard)
|
||||
.filter(
|
||||
LoyaltyCard.program_id == program_id,
|
||||
LoyaltyTransaction.points_delta < 0,
|
||||
LoyaltyTransaction.transaction_at >= thirty_days_ago,
|
||||
)
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
|
||||
# Estimated liability (unredeemed value)
|
||||
current_stamps = (
|
||||
db.query(func.sum(LoyaltyCard.stamp_count))
|
||||
@@ -677,6 +749,7 @@ class ProgramService:
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
total_points_balance = current_points
|
||||
# Rough estimate: assume 100 points = €1
|
||||
points_value_cents = current_points // 100 * 100
|
||||
|
||||
@@ -684,18 +757,28 @@ class ProgramService:
|
||||
(current_stamps * stamp_value // program.stamps_target) + points_value_cents
|
||||
)
|
||||
|
||||
avg_points_per_member = round(current_points / active_cards, 2) if active_cards > 0 else 0
|
||||
|
||||
return {
|
||||
"total_cards": total_cards,
|
||||
"active_cards": active_cards,
|
||||
"new_this_month": new_this_month,
|
||||
"total_stamps_issued": total_stamps_issued,
|
||||
"total_stamps_redeemed": total_stamps_redeemed,
|
||||
"stamps_this_month": stamps_this_month,
|
||||
"redemptions_this_month": redemptions_this_month,
|
||||
"total_points_issued": total_points_issued,
|
||||
"total_points_redeemed": total_points_redeemed,
|
||||
"total_points_balance": total_points_balance,
|
||||
"points_this_month": points_this_month,
|
||||
"points_redeemed_this_month": points_redeemed_this_month,
|
||||
"cards_with_activity_30d": cards_with_activity_30d,
|
||||
"average_stamps_per_card": round(avg_stamps, 2),
|
||||
"average_points_per_card": round(avg_points, 2),
|
||||
"avg_points_per_member": avg_points_per_member,
|
||||
"transactions_30d": transactions_30d,
|
||||
"points_issued_30d": points_issued_30d,
|
||||
"points_redeemed_30d": points_redeemed_30d,
|
||||
"estimated_liability_cents": estimated_liability,
|
||||
}
|
||||
|
||||
|
||||
@@ -59,11 +59,10 @@ function storeLoyaltyCards() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
await Promise.all([
|
||||
this.loadProgram(),
|
||||
this.loadCards(),
|
||||
this.loadStats()
|
||||
]);
|
||||
await this.loadProgram();
|
||||
if (this.program) {
|
||||
await Promise.all([this.loadCards(), this.loadStats()]);
|
||||
}
|
||||
} catch (error) {
|
||||
loyaltyCardsLog.error('Failed to load data:', error);
|
||||
this.error = error.message;
|
||||
|
||||
182
app/modules/loyalty/static/store/js/loyalty-settings.js
Normal file
182
app/modules/loyalty/static/store/js/loyalty-settings.js
Normal file
@@ -0,0 +1,182 @@
|
||||
// app/modules/loyalty/static/store/js/loyalty-settings.js
|
||||
// noqa: js-006 - async init pattern is safe, loadData has try/catch
|
||||
|
||||
const loyaltySettingsLog = window.LogConfig.loggers.loyaltySettings || window.LogConfig.createLogger('loyaltySettings');
|
||||
|
||||
// ============================================
|
||||
// STORE LOYALTY SETTINGS FUNCTION
|
||||
// ============================================
|
||||
function loyaltySettings() {
|
||||
return {
|
||||
// Inherit base layout functionality
|
||||
...data(),
|
||||
|
||||
// Page identifier
|
||||
currentPage: 'loyalty-settings',
|
||||
|
||||
// State
|
||||
program: null,
|
||||
loading: false,
|
||||
saving: false,
|
||||
error: null,
|
||||
isOwner: false,
|
||||
|
||||
// Form data
|
||||
form: {
|
||||
loyalty_type: 'points',
|
||||
stamps_target: 10,
|
||||
stamps_reward_description: 'Free item',
|
||||
stamps_reward_value_cents: null,
|
||||
points_per_euro: 10,
|
||||
welcome_bonus_points: 0,
|
||||
minimum_redemption_points: 100,
|
||||
minimum_purchase_cents: 0,
|
||||
points_expiration_days: null,
|
||||
points_rewards: [],
|
||||
cooldown_minutes: 15,
|
||||
max_daily_stamps: 5,
|
||||
require_staff_pin: true,
|
||||
card_name: '',
|
||||
card_color: '#4F46E5',
|
||||
logo_url: '',
|
||||
terms_text: '',
|
||||
},
|
||||
|
||||
// Initialize
|
||||
async init() {
|
||||
loyaltySettingsLog.info('=== LOYALTY SETTINGS INITIALIZING ===');
|
||||
|
||||
if (window._loyaltySettingsInitialized) {
|
||||
loyaltySettingsLog.warn('Already initialized, skipping...');
|
||||
return;
|
||||
}
|
||||
window._loyaltySettingsInitialized = true;
|
||||
|
||||
const parentInit = data().init;
|
||||
if (parentInit) {
|
||||
await parentInit.call(this);
|
||||
}
|
||||
|
||||
// Check if user is merchant_owner
|
||||
this.isOwner = this.currentUser?.role === 'merchant_owner';
|
||||
|
||||
await this.loadData();
|
||||
|
||||
loyaltySettingsLog.info('=== LOYALTY SETTINGS INITIALIZATION COMPLETE ===');
|
||||
},
|
||||
|
||||
async loadData() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
await this.loadProgram();
|
||||
} catch (error) {
|
||||
loyaltySettingsLog.error('Failed to load data:', error);
|
||||
this.error = error.message || 'Failed to load settings';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async loadProgram() {
|
||||
try {
|
||||
loyaltySettingsLog.info('Loading program...');
|
||||
const response = await apiClient.get('/store/loyalty/program');
|
||||
|
||||
if (response) {
|
||||
this.program = response;
|
||||
this.populateForm(response);
|
||||
loyaltySettingsLog.info('Program loaded:', response.display_name);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
loyaltySettingsLog.info('No program configured — showing create form');
|
||||
this.program = null;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
populateForm(program) {
|
||||
this.form.loyalty_type = program.loyalty_type || 'points';
|
||||
this.form.stamps_target = program.stamps_target || 10;
|
||||
this.form.stamps_reward_description = program.stamps_reward_description || 'Free item';
|
||||
this.form.stamps_reward_value_cents = program.stamps_reward_value_cents || null;
|
||||
this.form.points_per_euro = program.points_per_euro || 10;
|
||||
this.form.welcome_bonus_points = program.welcome_bonus_points || 0;
|
||||
this.form.minimum_redemption_points = program.minimum_redemption_points || 100;
|
||||
this.form.minimum_purchase_cents = program.minimum_purchase_cents || 0;
|
||||
this.form.points_expiration_days = program.points_expiration_days || null;
|
||||
this.form.points_rewards = (program.points_rewards || []).map(r => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
points_required: r.points_required,
|
||||
description: r.description || '',
|
||||
is_active: r.is_active !== false,
|
||||
}));
|
||||
this.form.cooldown_minutes = program.cooldown_minutes ?? 15;
|
||||
this.form.max_daily_stamps = program.max_daily_stamps || 5;
|
||||
this.form.require_staff_pin = program.require_staff_pin !== false;
|
||||
this.form.card_name = program.card_name || '';
|
||||
this.form.card_color = program.card_color || '#4F46E5';
|
||||
this.form.logo_url = program.logo_url || '';
|
||||
this.form.terms_text = program.terms_text || '';
|
||||
},
|
||||
|
||||
addReward() {
|
||||
const id = 'reward_' + Date.now();
|
||||
this.form.points_rewards.push({
|
||||
id: id,
|
||||
name: '',
|
||||
points_required: 100,
|
||||
description: '',
|
||||
is_active: true,
|
||||
});
|
||||
},
|
||||
|
||||
async saveProgram() {
|
||||
this.saving = true;
|
||||
|
||||
try {
|
||||
const payload = { ...this.form };
|
||||
|
||||
// Clean up empty optional fields
|
||||
if (!payload.stamps_reward_value_cents) payload.stamps_reward_value_cents = null;
|
||||
if (!payload.points_expiration_days) payload.points_expiration_days = null;
|
||||
if (!payload.card_name) payload.card_name = null;
|
||||
if (!payload.logo_url) payload.logo_url = null;
|
||||
if (!payload.terms_text) payload.terms_text = null;
|
||||
|
||||
let response;
|
||||
if (this.program) {
|
||||
// Update existing
|
||||
response = await apiClient.put('/store/loyalty/program', payload);
|
||||
Utils.showToast('Program updated successfully', 'success');
|
||||
} else {
|
||||
// Create new
|
||||
response = await apiClient.post('/store/loyalty/program', payload);
|
||||
Utils.showToast('Program created successfully', 'success');
|
||||
}
|
||||
|
||||
this.program = response;
|
||||
this.populateForm(response);
|
||||
|
||||
loyaltySettingsLog.info('Program saved:', response.display_name);
|
||||
} catch (error) {
|
||||
Utils.showToast(`Failed to save: ${error.message}`, 'error');
|
||||
loyaltySettingsLog.error('Save failed:', error);
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Register logger
|
||||
if (!window.LogConfig.loggers.loyaltySettings) {
|
||||
window.LogConfig.loggers.loyaltySettings = window.LogConfig.createLogger('loyaltySettings');
|
||||
}
|
||||
|
||||
loyaltySettingsLog.info('Loyalty settings module loaded');
|
||||
@@ -8,6 +8,8 @@ function storeLoyaltyStats() {
|
||||
...data(),
|
||||
currentPage: 'loyalty-stats',
|
||||
|
||||
program: null,
|
||||
|
||||
stats: {
|
||||
total_cards: 0,
|
||||
active_cards: 0,
|
||||
@@ -35,10 +37,22 @@ function storeLoyaltyStats() {
|
||||
await parentInit.call(this);
|
||||
}
|
||||
|
||||
await this.loadStats();
|
||||
await this.loadProgram();
|
||||
if (this.program) {
|
||||
await this.loadStats();
|
||||
}
|
||||
loyaltyStatsLog.info('=== LOYALTY STATS PAGE INITIALIZATION COMPLETE ===');
|
||||
},
|
||||
|
||||
async loadProgram() {
|
||||
try {
|
||||
const response = await apiClient.get('/store/loyalty/program');
|
||||
if (response) this.program = response;
|
||||
} catch (error) {
|
||||
if (error.status !== 404) throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async loadStats() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
@@ -191,7 +191,11 @@ function storeLoyaltyTerminal() {
|
||||
this.processing = true;
|
||||
|
||||
try {
|
||||
if (this.pendingAction === 'earn') {
|
||||
if (this.pendingAction === 'stamp') {
|
||||
await this.addStamp();
|
||||
} else if (this.pendingAction === 'redeemStamps') {
|
||||
await this.redeemStamps();
|
||||
} else if (this.pendingAction === 'earn') {
|
||||
await this.earnPoints();
|
||||
} else if (this.pendingAction === 'redeem') {
|
||||
await this.redeemReward();
|
||||
@@ -216,6 +220,30 @@ function storeLoyaltyTerminal() {
|
||||
}
|
||||
},
|
||||
|
||||
// Add stamp
|
||||
async addStamp() {
|
||||
loyaltyTerminalLog.info('Adding stamp...');
|
||||
|
||||
await apiClient.post('/store/loyalty/stamp', {
|
||||
card_id: this.selectedCard.id,
|
||||
staff_pin: this.pinDigits
|
||||
});
|
||||
|
||||
Utils.showToast('Stamp added!', 'success');
|
||||
},
|
||||
|
||||
// Redeem stamps
|
||||
async redeemStamps() {
|
||||
loyaltyTerminalLog.info('Redeeming stamps...');
|
||||
|
||||
await apiClient.post('/store/loyalty/stamp/redeem', {
|
||||
card_id: this.selectedCard.id,
|
||||
staff_pin: this.pinDigits
|
||||
});
|
||||
|
||||
Utils.showToast('Stamps redeemed! Reward earned.', 'success');
|
||||
},
|
||||
|
||||
// Earn points
|
||||
async earnPoints() {
|
||||
loyaltyTerminalLog.info('Earning points...', { amount: this.earnAmount });
|
||||
|
||||
@@ -26,8 +26,28 @@
|
||||
|
||||
{{ error_state('Error loading members') }}
|
||||
|
||||
<!-- No Program Setup Notice -->
|
||||
<div x-show="!loading && !program" class="mb-6 px-4 py-5 bg-yellow-50 border border-yellow-200 rounded-lg dark:bg-yellow-900/20 dark:border-yellow-800">
|
||||
<div class="flex items-start">
|
||||
<span x-html="$icon('exclamation-triangle', 'w-6 h-6 text-yellow-500 mr-3 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-yellow-800 dark:text-yellow-200">Loyalty Program Not Set Up</h3>
|
||||
<p class="mt-1 text-sm text-yellow-700 dark:text-yellow-300">Your merchant doesn't have a loyalty program configured yet.</p>
|
||||
{% if user.role == 'merchant_owner' %}
|
||||
<a href="/store/{{ store_code }}/loyalty/settings"
|
||||
class="mt-3 inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-yellow-600 rounded-lg hover:bg-yellow-700">
|
||||
<span x-html="$icon('cog', 'w-4 h-4 mr-2')"></span>
|
||||
Set Up Loyalty Program
|
||||
</a>
|
||||
{% else %}
|
||||
<p class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">Contact your administrator to complete the setup.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-4">
|
||||
<div x-show="!loading && program" class="grid gap-6 mb-8 md:grid-cols-4">
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
|
||||
<span x-html="$icon('users', 'w-5 h-5')"></span>
|
||||
@@ -67,7 +87,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Search and Filters -->
|
||||
<div x-show="!loading" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div x-show="!loading && program" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<div class="relative">
|
||||
@@ -91,7 +111,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Cards Table -->
|
||||
<div x-show="!loading">
|
||||
<div x-show="!loading && program">
|
||||
{% call table_wrapper() %}
|
||||
{{ table_header(['Member', 'Card Number', 'Points Balance', 'Last Activity', 'Status', 'Actions']) }}
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
|
||||
246
app/modules/loyalty/templates/loyalty/store/settings.html
Normal file
246
app/modules/loyalty/templates/loyalty/store/settings.html
Normal file
@@ -0,0 +1,246 @@
|
||||
{# app/modules/loyalty/templates/loyalty/store/settings.html #}
|
||||
{% extends "store/base.html" %}
|
||||
{% from 'shared/macros/headers.html' import page_header_flex %}
|
||||
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
|
||||
|
||||
{% block title %}Loyalty Settings{% endblock %}
|
||||
|
||||
{% block alpine_data %}loyaltySettings(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Page Header -->
|
||||
{% call page_header_flex(title='Loyalty Settings', subtitle='Configure your loyalty program') %}
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/store/{{ store_code }}/loyalty/terminal"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700">
|
||||
<span x-html="$icon('terminal', 'w-4 h-4 mr-2')"></span>
|
||||
Terminal
|
||||
</a>
|
||||
</div>
|
||||
{% endcall %}
|
||||
|
||||
{{ loading_state('Loading settings...') }}
|
||||
|
||||
{{ error_state('Error loading settings') }}
|
||||
|
||||
<!-- Access Denied (non-owner) -->
|
||||
<div x-show="!loading && !isOwner" class="mb-6 px-4 py-5 bg-yellow-50 border border-yellow-200 rounded-lg dark:bg-yellow-900/20 dark:border-yellow-800">
|
||||
<div class="flex items-start">
|
||||
<span x-html="$icon('shield', 'w-6 h-6 text-yellow-500 mr-3 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-yellow-800 dark:text-yellow-200">Access Restricted</h3>
|
||||
<p class="mt-1 text-sm text-yellow-700 dark:text-yellow-300">Only the merchant owner can manage loyalty program settings.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Form -->
|
||||
<div x-show="!loading && isOwner" class="space-y-6">
|
||||
|
||||
<!-- Program Type (create only) -->
|
||||
<div x-show="!program" class="bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
<span x-html="$icon('layers', 'inline w-5 h-5 mr-2')"></span>
|
||||
Program Type
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<label class="relative flex items-start p-4 border-2 rounded-lg cursor-pointer transition-colors"
|
||||
:class="form.loyalty_type === 'stamps' ? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-200 dark:border-gray-700 hover:border-gray-300'">
|
||||
<input type="radio" x-model="form.loyalty_type" value="stamps" class="sr-only">
|
||||
<div>
|
||||
<span x-html="$icon('stamp', 'w-8 h-8 text-purple-500 mb-2')"></span>
|
||||
<p class="font-semibold text-gray-700 dark:text-gray-200">Stamps</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Collect stamps for rewards</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="relative flex items-start p-4 border-2 rounded-lg cursor-pointer transition-colors"
|
||||
:class="form.loyalty_type === 'points' ? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-200 dark:border-gray-700 hover:border-gray-300'">
|
||||
<input type="radio" x-model="form.loyalty_type" value="points" class="sr-only">
|
||||
<div>
|
||||
<span x-html="$icon('coins', 'w-8 h-8 text-purple-500 mb-2')"></span>
|
||||
<p class="font-semibold text-gray-700 dark:text-gray-200">Points</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Earn points per purchase</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="relative flex items-start p-4 border-2 rounded-lg cursor-pointer transition-colors"
|
||||
:class="form.loyalty_type === 'hybrid' ? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-200 dark:border-gray-700 hover:border-gray-300'">
|
||||
<input type="radio" x-model="form.loyalty_type" value="hybrid" class="sr-only">
|
||||
<div>
|
||||
<span x-html="$icon('layers', 'w-8 h-8 text-purple-500 mb-2')"></span>
|
||||
<p class="font-semibold text-gray-700 dark:text-gray-200">Hybrid</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Both stamps and points</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stamps Configuration -->
|
||||
<div x-show="form.loyalty_type === 'stamps' || form.loyalty_type === 'hybrid'"
|
||||
class="bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Stamps Configuration</h3>
|
||||
</div>
|
||||
<div class="p-4 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Stamps for Reward</label>
|
||||
<input type="number" x-model.number="form.stamps_target" min="1" max="50"
|
||||
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Reward Description</label>
|
||||
<input type="text" x-model="form.stamps_reward_description" maxlength="255"
|
||||
placeholder="e.g., Free coffee"
|
||||
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Reward Value (cents)</label>
|
||||
<input type="number" x-model.number="form.stamps_reward_value_cents" min="0"
|
||||
placeholder="e.g., 500 for 5 EUR"
|
||||
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Points Configuration -->
|
||||
<div x-show="form.loyalty_type === 'points' || form.loyalty_type === 'hybrid'"
|
||||
class="bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Points Configuration</h3>
|
||||
</div>
|
||||
<div class="p-4 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Points per EUR</label>
|
||||
<input type="number" x-model.number="form.points_per_euro" min="1" max="100"
|
||||
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Welcome Bonus Points</label>
|
||||
<input type="number" x-model.number="form.welcome_bonus_points" min="0"
|
||||
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Min Redemption Points</label>
|
||||
<input type="number" x-model.number="form.minimum_redemption_points" min="1"
|
||||
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Min Purchase (cents)</label>
|
||||
<input type="number" x-model.number="form.minimum_purchase_cents" min="0"
|
||||
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Points Expiry (days)</label>
|
||||
<input type="number" x-model.number="form.points_expiration_days" min="30"
|
||||
placeholder="Leave empty for no expiry"
|
||||
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rewards List -->
|
||||
<div class="px-4 pb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Rewards</label>
|
||||
<div class="space-y-2">
|
||||
<template x-for="(reward, index) in form.points_rewards" :key="index">
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="text" x-model="reward.name" placeholder="Reward name"
|
||||
class="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
|
||||
<input type="number" x-model.number="reward.points_required" placeholder="Points" min="1"
|
||||
class="w-24 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
|
||||
<button @click="form.points_rewards.splice(index, 1)" type="button"
|
||||
class="p-2 text-red-500 hover:text-red-700">
|
||||
<span x-html="$icon('x', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<button @click="addReward()" type="button"
|
||||
class="mt-2 text-sm text-purple-600 hover:text-purple-800 dark:text-purple-400">
|
||||
+ Add Reward
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Anti-Fraud -->
|
||||
<div class="bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Anti-Fraud Settings</h3>
|
||||
</div>
|
||||
<div class="p-4 grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Cooldown (minutes)</label>
|
||||
<input type="number" x-model.number="form.cooldown_minutes" min="0" max="1440"
|
||||
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Max Daily Stamps</label>
|
||||
<input type="number" x-model.number="form.max_daily_stamps" min="1" max="50"
|
||||
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" x-model="form.require_staff_pin"
|
||||
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Require Staff PIN</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Branding -->
|
||||
<div class="bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Branding</h3>
|
||||
</div>
|
||||
<div class="p-4 grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Card Name</label>
|
||||
<input type="text" x-model="form.card_name" maxlength="100"
|
||||
placeholder="e.g., My Rewards Card"
|
||||
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Card Color</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="color" x-model="form.card_color"
|
||||
class="w-10 h-10 rounded border border-gray-300 dark:border-gray-600 cursor-pointer">
|
||||
<input type="text" x-model="form.card_color" maxlength="7" placeholder="#4F46E5"
|
||||
class="flex-1 px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Logo URL</label>
|
||||
<input type="url" x-model="form.logo_url" maxlength="500"
|
||||
placeholder="https://..."
|
||||
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Terms & Conditions</label>
|
||||
<textarea x-model="form.terms_text" rows="3"
|
||||
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<a href="/store/{{ store_code }}/loyalty/terminal"
|
||||
class="px-6 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600">
|
||||
Cancel
|
||||
</a>
|
||||
<button @click="saveProgram()"
|
||||
:disabled="saving"
|
||||
class="px-6 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||
<span x-show="saving" x-html="$icon('spinner', 'w-4 h-4 mr-2 inline animate-spin')"></span>
|
||||
<span x-text="saving ? 'Saving...' : (program ? 'Save Changes' : 'Create Program')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script defer src="{{ url_for('loyalty_static', path='store/js/loyalty-settings.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -17,7 +17,27 @@
|
||||
{{ loading_state('Loading statistics...') }}
|
||||
{{ error_state('Error loading statistics') }}
|
||||
|
||||
<div x-show="!loading">
|
||||
<!-- No Program Setup Notice -->
|
||||
<div x-show="!loading && !program" class="mb-6 px-4 py-5 bg-yellow-50 border border-yellow-200 rounded-lg dark:bg-yellow-900/20 dark:border-yellow-800">
|
||||
<div class="flex items-start">
|
||||
<span x-html="$icon('exclamation-triangle', 'w-6 h-6 text-yellow-500 mr-3 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-yellow-800 dark:text-yellow-200">Loyalty Program Not Set Up</h3>
|
||||
<p class="mt-1 text-sm text-yellow-700 dark:text-yellow-300">Your merchant doesn't have a loyalty program configured yet.</p>
|
||||
{% if user.role == 'merchant_owner' %}
|
||||
<a href="/store/{{ store_code }}/loyalty/settings"
|
||||
class="mt-3 inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-yellow-600 rounded-lg hover:bg-yellow-700">
|
||||
<span x-html="$icon('cog', 'w-4 h-4 mr-2')"></span>
|
||||
Set Up Loyalty Program
|
||||
</a>
|
||||
{% else %}
|
||||
<p class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">Contact your administrator to complete the setup.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="!loading && program">
|
||||
<!-- Summary Stats -->
|
||||
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
|
||||
@@ -36,11 +36,15 @@
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-yellow-800 dark:text-yellow-200">Loyalty Program Not Set Up</h3>
|
||||
<p class="mt-1 text-sm text-yellow-700 dark:text-yellow-300">Your merchant doesn't have a loyalty program configured yet.</p>
|
||||
{% if user.role == 'merchant_owner' %}
|
||||
<a href="/store/{{ store_code }}/loyalty/settings"
|
||||
class="mt-3 inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-yellow-600 rounded-lg hover:bg-yellow-700">
|
||||
<span x-html="$icon('cog', 'w-4 h-4 mr-2')"></span>
|
||||
Set Up Loyalty Program
|
||||
</a>
|
||||
{% else %}
|
||||
<p class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">Contact your administrator to complete the setup.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -125,70 +129,127 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Points Balance -->
|
||||
<!-- Balance Area -->
|
||||
<div class="mb-6 p-4 rounded-lg text-center"
|
||||
:style="'background-color: ' + (program?.card_color || '#4F46E5') + '10'">
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Points Balance</p>
|
||||
<p class="text-3xl font-bold"
|
||||
:style="'color: ' + (program?.card_color || '#4F46E5')"
|
||||
x-text="formatNumber(selectedCard?.points_balance || 0)"></p>
|
||||
<!-- Points balance (for points and hybrid) -->
|
||||
<template x-if="program?.is_points_enabled">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Points Balance</p>
|
||||
<p class="text-3xl font-bold"
|
||||
:style="'color: ' + (program?.card_color || '#4F46E5')"
|
||||
x-text="formatNumber(selectedCard?.points_balance || 0)"></p>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Stamps progress (for stamps and hybrid) -->
|
||||
<template x-if="program?.is_stamps_enabled">
|
||||
<div :class="program?.is_points_enabled ? 'mt-3 pt-3 border-t border-gray-200 dark:border-gray-700' : ''">
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Stamps</p>
|
||||
<p class="text-3xl font-bold"
|
||||
:style="'color: ' + (program?.card_color || '#4F46E5')"
|
||||
x-text="(selectedCard?.stamp_count || 0) + ' / ' + (program?.stamps_target || 10)"></p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1"
|
||||
x-text="selectedCard?.stamps_until_reward > 0 ? (selectedCard.stamps_until_reward + ' more for reward') : 'Ready to redeem!'"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<!-- Action Panels -->
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<!-- Earn Points -->
|
||||
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
<span x-html="$icon('plus-circle', 'inline w-4 h-4 mr-1 text-green-500')"></span>
|
||||
Earn Points
|
||||
</h4>
|
||||
<div class="mb-3">
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Purchase Amount</label>
|
||||
<div class="relative">
|
||||
<span class="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-500">EUR</span>
|
||||
<input type="number" step="0.01" min="0" {# noqa: FE-008 #}
|
||||
x-model.number="earnAmount"
|
||||
class="w-full pl-12 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-green-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
Points to award: <span class="font-semibold text-green-600" x-text="Math.floor((earnAmount || 0) * (program?.points_per_euro || 1))"></span>
|
||||
</p>
|
||||
<button @click="showPinModal('earn')"
|
||||
:disabled="!earnAmount || earnAmount <= 0"
|
||||
class="w-full px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 disabled:opacity-50">
|
||||
Award Points
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Redeem Points -->
|
||||
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
<span x-html="$icon('gift', 'inline w-4 h-4 mr-1 text-orange-500')"></span>
|
||||
Redeem Reward
|
||||
</h4>
|
||||
<div class="mb-3">
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Select Reward</label>
|
||||
<select x-model="selectedReward"
|
||||
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-orange-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
<option value="">Select reward...</option>
|
||||
<template x-for="reward in availableRewards" :key="reward.id">
|
||||
<option :value="reward.id" :disabled="(selectedCard?.points_balance || 0) < reward.points_required"
|
||||
x-text="reward.name + ' (' + reward.points_required + ' pts)'"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<template x-if="selectedReward">
|
||||
<!-- Stamp Panels (for stamps and hybrid) -->
|
||||
<template x-if="program?.is_stamps_enabled">
|
||||
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
<span x-html="$icon('plus-circle', 'inline w-4 h-4 mr-1 text-green-500')"></span>
|
||||
Add Stamp
|
||||
</h4>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
Points after: <span class="font-semibold text-orange-600" x-text="formatNumber((selectedCard?.points_balance || 0) - (getSelectedRewardPoints() || 0))"></span>
|
||||
Current: <span class="font-semibold" x-text="(selectedCard?.stamp_count || 0) + '/' + (program?.stamps_target || 10)"></span>
|
||||
</p>
|
||||
</template>
|
||||
<button @click="showPinModal('redeem')"
|
||||
:disabled="!selectedReward"
|
||||
class="w-full px-4 py-2 text-sm font-medium text-white bg-orange-600 rounded-lg hover:bg-orange-700 disabled:opacity-50">
|
||||
Redeem Reward
|
||||
</button>
|
||||
</div>
|
||||
<button @click="showPinModal('stamp')"
|
||||
:disabled="!selectedCard?.can_stamp"
|
||||
class="w-full px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 disabled:opacity-50">
|
||||
Add Stamp
|
||||
</button>
|
||||
<template x-if="!selectedCard?.can_stamp && selectedCard?.cooldown_ends_at">
|
||||
<p class="text-xs text-red-500 mt-2">Cooldown active</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="program?.is_stamps_enabled">
|
||||
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
<span x-html="$icon('gift', 'inline w-4 h-4 mr-1 text-orange-500')"></span>
|
||||
Redeem Stamps
|
||||
</h4>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3"
|
||||
x-text="selectedCard?.can_redeem_stamps ? 'Reward: ' + (selectedCard?.stamp_reward_description || program?.stamps_reward_description || 'Free item') : 'Not enough stamps yet'"></p>
|
||||
<button @click="showPinModal('redeemStamps')"
|
||||
:disabled="!selectedCard?.can_redeem_stamps"
|
||||
class="w-full px-4 py-2 text-sm font-medium text-white bg-orange-600 rounded-lg hover:bg-orange-700 disabled:opacity-50">
|
||||
Redeem Stamps
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Point Panels (for points and hybrid) -->
|
||||
<template x-if="program?.is_points_enabled">
|
||||
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
<span x-html="$icon('plus-circle', 'inline w-4 h-4 mr-1 text-green-500')"></span>
|
||||
Earn Points
|
||||
</h4>
|
||||
<div class="mb-3">
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Purchase Amount</label>
|
||||
<div class="relative">
|
||||
<span class="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-500">EUR</span>
|
||||
<input type="number" step="0.01" min="0" {# noqa: FE-008 #}
|
||||
x-model.number="earnAmount"
|
||||
class="w-full pl-12 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-green-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
Points to award: <span class="font-semibold text-green-600" x-text="Math.floor((earnAmount || 0) * (program?.points_per_euro || 1))"></span>
|
||||
</p>
|
||||
<button @click="showPinModal('earn')"
|
||||
:disabled="!earnAmount || earnAmount <= 0"
|
||||
class="w-full px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 disabled:opacity-50">
|
||||
Award Points
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="program?.is_points_enabled">
|
||||
<div class="p-4 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<h4 class="font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
<span x-html="$icon('gift', 'inline w-4 h-4 mr-1 text-orange-500')"></span>
|
||||
Redeem Reward
|
||||
</h4>
|
||||
<div class="mb-3">
|
||||
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Select Reward</label>
|
||||
<select x-model="selectedReward"
|
||||
class="w-full px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-orange-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300">
|
||||
<option value="">Select reward...</option>
|
||||
<template x-for="reward in availableRewards" :key="reward.id">
|
||||
<option :value="reward.id" :disabled="(selectedCard?.points_balance || 0) < reward.points_required"
|
||||
x-text="reward.name + ' (' + reward.points_required + ' pts)'"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<template x-if="selectedReward">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
Points after: <span class="font-semibold text-orange-600" x-text="formatNumber((selectedCard?.points_balance || 0) - (getSelectedRewardPoints() || 0))"></span>
|
||||
</p>
|
||||
</template>
|
||||
<button @click="showPinModal('redeem')"
|
||||
:disabled="!selectedReward"
|
||||
class="w-full px-4 py-2 text-sm font-medium text-white bg-orange-600 rounded-lg hover:bg-orange-700 disabled:opacity-50">
|
||||
Redeem Reward
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,12 +12,15 @@ Tests:
|
||||
Authentication: Uses real JWT tokens via store login endpoint.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.loyalty.models import LoyaltyTransaction
|
||||
from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram, LoyaltyTransaction
|
||||
from app.modules.loyalty.models.loyalty_program import LoyaltyType
|
||||
from app.modules.loyalty.models.loyalty_transaction import TransactionType
|
||||
from app.modules.tenancy.models import User
|
||||
|
||||
BASE = "/api/v1/store/loyalty"
|
||||
|
||||
@@ -217,42 +220,20 @@ class TestEarnPoints:
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Store Program CRUD Removed
|
||||
# Store Program CRUD
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.api
|
||||
@pytest.mark.loyalty
|
||||
class TestStoreProgramCrudRemoved:
|
||||
"""Verify POST/PATCH /program endpoints are removed from store API."""
|
||||
class TestStoreProgramCrud:
|
||||
"""Tests for store program CRUD endpoints (merchant_owner only)."""
|
||||
|
||||
def test_create_program_removed(
|
||||
self, client, loyalty_store_headers
|
||||
):
|
||||
"""POST /program no longer exists on store API."""
|
||||
response = client.post(
|
||||
f"{BASE}/program",
|
||||
json={"loyalty_type": "points"},
|
||||
headers=loyalty_store_headers,
|
||||
)
|
||||
assert response.status_code == 405 # Method Not Allowed
|
||||
|
||||
def test_update_program_removed(
|
||||
self, client, loyalty_store_headers
|
||||
):
|
||||
"""PATCH /program no longer exists on store API."""
|
||||
response = client.patch(
|
||||
f"{BASE}/program",
|
||||
json={"card_name": "Should Not Work"},
|
||||
headers=loyalty_store_headers,
|
||||
)
|
||||
assert response.status_code == 405 # Method Not Allowed
|
||||
|
||||
def test_get_program_still_works(
|
||||
def test_get_program_works(
|
||||
self, client, loyalty_store_headers, loyalty_store_setup
|
||||
):
|
||||
"""GET /program still works (read-only)."""
|
||||
"""GET /program returns program."""
|
||||
response = client.get(
|
||||
f"{BASE}/program",
|
||||
headers=loyalty_store_headers,
|
||||
@@ -261,6 +242,19 @@ class TestStoreProgramCrudRemoved:
|
||||
data = response.json()
|
||||
assert data["id"] == loyalty_store_setup["program"].id
|
||||
|
||||
def test_update_program_via_put(
|
||||
self, client, loyalty_store_headers, loyalty_store_setup
|
||||
):
|
||||
"""PUT /program updates the program (merchant_owner)."""
|
||||
response = client.put(
|
||||
f"{BASE}/program",
|
||||
json={"card_name": "Updated Card"},
|
||||
headers=loyalty_store_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["card_name"] == "Updated Card"
|
||||
|
||||
def test_stats_still_works(
|
||||
self, client, loyalty_store_headers, loyalty_store_setup
|
||||
):
|
||||
@@ -270,3 +264,201 @@ class TestStoreProgramCrudRemoved:
|
||||
headers=loyalty_store_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_stats_has_new_fields(
|
||||
self, client, loyalty_store_headers, loyalty_store_setup
|
||||
):
|
||||
"""GET /stats returns all new fields."""
|
||||
response = client.get(
|
||||
f"{BASE}/stats",
|
||||
headers=loyalty_store_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "new_this_month" in data
|
||||
assert "total_points_balance" in data
|
||||
assert "avg_points_per_member" in data
|
||||
assert "transactions_30d" in data
|
||||
assert "points_issued_30d" in data
|
||||
assert "points_redeemed_30d" in data
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Stamp Earn/Redeem Integration Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stamp_store_setup(db, loyalty_platform):
|
||||
"""Setup with a stamps-type program for integration tests."""
|
||||
from app.modules.customers.models.customer import Customer
|
||||
from app.modules.tenancy.models.store import StoreUser
|
||||
from app.modules.tenancy.models.store_platform import StorePlatform
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
|
||||
owner = User(
|
||||
email=f"stampint_{uid}@test.com",
|
||||
username=f"stampint_{uid}",
|
||||
hashed_password=auth.hash_password("storepass123"),
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
db.add(owner)
|
||||
db.commit()
|
||||
db.refresh(owner)
|
||||
|
||||
from app.modules.tenancy.models import Merchant, Store
|
||||
|
||||
merchant = Merchant(
|
||||
name=f"Stamp Int Merchant {uid}",
|
||||
owner_user_id=owner.id,
|
||||
contact_email=owner.email,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(merchant)
|
||||
db.commit()
|
||||
db.refresh(merchant)
|
||||
|
||||
store = Store(
|
||||
merchant_id=merchant.id,
|
||||
store_code=f"STINT_{uid.upper()}",
|
||||
subdomain=f"stint{uid}",
|
||||
name=f"Stamp Int Store {uid}",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store)
|
||||
db.commit()
|
||||
db.refresh(store)
|
||||
|
||||
store_user = StoreUser(store_id=store.id, user_id=owner.id, is_active=True)
|
||||
db.add(store_user)
|
||||
db.commit()
|
||||
|
||||
sp = StorePlatform(store_id=store.id, platform_id=loyalty_platform.id)
|
||||
db.add(sp)
|
||||
db.commit()
|
||||
|
||||
customer = Customer(
|
||||
email=f"stampcust_{uid}@test.com",
|
||||
first_name="Stamp",
|
||||
last_name="IntCustomer",
|
||||
hashed_password="!unused!", # noqa: SEC001
|
||||
customer_number=f"SIC-{uid.upper()}",
|
||||
store_id=store.id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(customer)
|
||||
db.commit()
|
||||
db.refresh(customer)
|
||||
|
||||
program = LoyaltyProgram(
|
||||
merchant_id=merchant.id,
|
||||
loyalty_type=LoyaltyType.HYBRID.value,
|
||||
stamps_target=3,
|
||||
stamps_reward_description="Free item",
|
||||
stamps_reward_value_cents=500,
|
||||
points_per_euro=10,
|
||||
welcome_bonus_points=0,
|
||||
minimum_redemption_points=100,
|
||||
minimum_purchase_cents=0,
|
||||
cooldown_minutes=0,
|
||||
max_daily_stamps=50,
|
||||
require_staff_pin=False,
|
||||
card_name="Int Stamp Card",
|
||||
card_color="#FF0000",
|
||||
is_active=True,
|
||||
points_rewards=[
|
||||
{"id": "r1", "name": "5 EUR off", "points_required": 100, "is_active": True},
|
||||
],
|
||||
)
|
||||
db.add(program)
|
||||
db.commit()
|
||||
db.refresh(program)
|
||||
|
||||
card = LoyaltyCard(
|
||||
merchant_id=merchant.id,
|
||||
program_id=program.id,
|
||||
customer_id=customer.id,
|
||||
enrolled_at_store_id=store.id,
|
||||
card_number=f"SINTCARD-{uid.upper()}",
|
||||
stamp_count=0,
|
||||
total_stamps_earned=0,
|
||||
stamps_redeemed=0,
|
||||
points_balance=0,
|
||||
total_points_earned=0,
|
||||
points_redeemed=0,
|
||||
is_active=True,
|
||||
last_activity_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(card)
|
||||
db.commit()
|
||||
db.refresh(card)
|
||||
|
||||
return {
|
||||
"owner": owner,
|
||||
"merchant": merchant,
|
||||
"store": store,
|
||||
"platform": loyalty_platform,
|
||||
"customer": customer,
|
||||
"program": program,
|
||||
"card": card,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stamp_store_headers(client, stamp_store_setup):
|
||||
"""JWT auth headers for stamp store setup."""
|
||||
owner = stamp_store_setup["owner"]
|
||||
response = client.post(
|
||||
"/api/v1/store/auth/login",
|
||||
json={"email_or_username": owner.username, "password": "storepass123"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
token = response.json()["access_token"]
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.api
|
||||
@pytest.mark.loyalty
|
||||
class TestStampEarnRedeem:
|
||||
"""Integration tests for stamp earn/redeem via store API."""
|
||||
|
||||
def test_stamp_earn(self, client, stamp_store_headers, stamp_store_setup):
|
||||
"""POST /stamp adds a stamp."""
|
||||
card = stamp_store_setup["card"]
|
||||
response = client.post(
|
||||
f"{BASE}/stamp",
|
||||
json={"card_id": card.id},
|
||||
headers=stamp_store_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["stamp_count"] == 1
|
||||
|
||||
def test_stamp_redeem(self, client, stamp_store_headers, stamp_store_setup, db):
|
||||
"""POST /stamp/redeem redeems stamps."""
|
||||
card = stamp_store_setup["card"]
|
||||
program = stamp_store_setup["program"]
|
||||
|
||||
# Give enough stamps
|
||||
card.stamp_count = program.stamps_target
|
||||
card.total_stamps_earned = program.stamps_target
|
||||
db.commit()
|
||||
|
||||
response = client.post(
|
||||
f"{BASE}/stamp/redeem",
|
||||
json={"card_id": card.id},
|
||||
headers=stamp_store_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert data["stamp_count"] == 0
|
||||
|
||||
0
app/modules/loyalty/tests/unit/__init__.py
Normal file
0
app/modules/loyalty/tests/unit/__init__.py
Normal file
@@ -1,8 +1,20 @@
|
||||
"""Unit tests for PinService."""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.loyalty.exceptions import (
|
||||
InvalidStaffPinException,
|
||||
StaffPinLockedException,
|
||||
)
|
||||
from app.modules.loyalty.models import LoyaltyProgram, StaffPin
|
||||
from app.modules.loyalty.models.loyalty_program import LoyaltyType
|
||||
from app.modules.loyalty.schemas.pin import PinCreate
|
||||
from app.modules.loyalty.services.pin_service import PinService
|
||||
from app.modules.tenancy.models import Merchant, Store, User
|
||||
from app.modules.tenancy.models.store import StoreUser
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -16,3 +28,208 @@ class TestPinService:
|
||||
def test_service_instantiation(self):
|
||||
"""Service can be instantiated."""
|
||||
assert self.service is not None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Fixtures
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pin_setup(db):
|
||||
"""Create a full setup for PIN tests."""
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
|
||||
owner = User(
|
||||
email=f"pinowner_{uid}@test.com",
|
||||
username=f"pinowner_{uid}",
|
||||
hashed_password=auth.hash_password("testpass"),
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
db.add(owner)
|
||||
db.commit()
|
||||
db.refresh(owner)
|
||||
|
||||
merchant = Merchant(
|
||||
name=f"PIN Merchant {uid}",
|
||||
owner_user_id=owner.id,
|
||||
contact_email=owner.email,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(merchant)
|
||||
db.commit()
|
||||
db.refresh(merchant)
|
||||
|
||||
store = Store(
|
||||
merchant_id=merchant.id,
|
||||
store_code=f"PIN_{uid.upper()}",
|
||||
subdomain=f"pin{uid}",
|
||||
name=f"PIN Store {uid}",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store)
|
||||
db.commit()
|
||||
db.refresh(store)
|
||||
|
||||
store_user = StoreUser(store_id=store.id, user_id=owner.id, is_active=True)
|
||||
db.add(store_user)
|
||||
db.commit()
|
||||
|
||||
program = LoyaltyProgram(
|
||||
merchant_id=merchant.id,
|
||||
loyalty_type=LoyaltyType.POINTS.value,
|
||||
points_per_euro=10,
|
||||
cooldown_minutes=0,
|
||||
max_daily_stamps=10,
|
||||
require_staff_pin=True,
|
||||
card_name="PIN Card",
|
||||
card_color="#00FF00",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(program)
|
||||
db.commit()
|
||||
db.refresh(program)
|
||||
|
||||
return {
|
||||
"merchant": merchant,
|
||||
"store": store,
|
||||
"program": program,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Create / Unlock Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestCreatePin:
|
||||
"""Tests for create_pin."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = PinService()
|
||||
|
||||
def test_create_pin(self, db, pin_setup):
|
||||
"""Create a staff PIN."""
|
||||
program = pin_setup["program"]
|
||||
store = pin_setup["store"]
|
||||
|
||||
data = PinCreate(name="Alice", staff_id="EMP001", pin="1234")
|
||||
pin = self.service.create_pin(db, program.id, store.id, data)
|
||||
|
||||
assert pin.id is not None
|
||||
assert pin.name == "Alice"
|
||||
assert pin.staff_id == "EMP001"
|
||||
assert pin.verify_pin("1234")
|
||||
|
||||
def test_unlock_pin(self, db, pin_setup):
|
||||
"""Unlock a locked PIN."""
|
||||
program = pin_setup["program"]
|
||||
store = pin_setup["store"]
|
||||
|
||||
data = PinCreate(name="Bob", staff_id="EMP002", pin="5678")
|
||||
pin = self.service.create_pin(db, program.id, store.id, data)
|
||||
|
||||
# Lock it
|
||||
pin.failed_attempts = 5
|
||||
from datetime import timedelta
|
||||
pin.locked_until = datetime.now(UTC) + timedelta(minutes=30)
|
||||
db.commit()
|
||||
|
||||
assert pin.is_locked
|
||||
|
||||
# Unlock
|
||||
unlocked = self.service.unlock_pin(db, pin.id)
|
||||
assert unlocked.failed_attempts == 0
|
||||
assert unlocked.locked_until is None
|
||||
assert not unlocked.is_locked
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Verify PIN Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestVerifyPin:
|
||||
"""Tests for verify_pin."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = PinService()
|
||||
|
||||
def test_verify_pin_success(self, db, pin_setup):
|
||||
"""Correct PIN verifies successfully."""
|
||||
program = pin_setup["program"]
|
||||
store = pin_setup["store"]
|
||||
|
||||
data = PinCreate(name="Charlie", staff_id="EMP003", pin="1111")
|
||||
self.service.create_pin(db, program.id, store.id, data)
|
||||
|
||||
result = self.service.verify_pin(db, program.id, "1111", store_id=store.id)
|
||||
assert result.name == "Charlie"
|
||||
|
||||
def test_verify_pin_wrong_single_failure(self, db, pin_setup):
|
||||
"""Wrong PIN records failure on ONE pin only, not all."""
|
||||
program = pin_setup["program"]
|
||||
store = pin_setup["store"]
|
||||
|
||||
# Create two PINs
|
||||
self.service.create_pin(db, program.id, store.id, PinCreate(name="A", pin="1111"))
|
||||
self.service.create_pin(db, program.id, store.id, PinCreate(name="B", pin="2222"))
|
||||
|
||||
# Wrong PIN
|
||||
with pytest.raises(InvalidStaffPinException):
|
||||
self.service.verify_pin(db, program.id, "9999", store_id=store.id)
|
||||
|
||||
# Only one PIN should have failed_attempts incremented
|
||||
pins = self.service.list_pins(db, program.id, store_id=store.id, is_active=True)
|
||||
failed_counts = [p.failed_attempts for p in pins]
|
||||
assert sum(failed_counts) == 1 # Only 1 PIN got the failure, not both
|
||||
|
||||
def test_verify_pin_lockout(self, db, pin_setup):
|
||||
"""After max failures, PIN gets locked."""
|
||||
program = pin_setup["program"]
|
||||
store = pin_setup["store"]
|
||||
|
||||
self.service.create_pin(db, program.id, store.id, PinCreate(name="Lock", pin="3333"))
|
||||
|
||||
# Fail 5 times (default max)
|
||||
for _ in range(5):
|
||||
try:
|
||||
self.service.verify_pin(db, program.id, "9999", store_id=store.id)
|
||||
except (InvalidStaffPinException, StaffPinLockedException):
|
||||
pass
|
||||
|
||||
# Next attempt should be locked
|
||||
with pytest.raises((InvalidStaffPinException, StaffPinLockedException)):
|
||||
self.service.verify_pin(db, program.id, "9999", store_id=store.id)
|
||||
|
||||
def test_verify_skips_locked_pins(self, db, pin_setup):
|
||||
"""Locked PINs are skipped during verification."""
|
||||
program = pin_setup["program"]
|
||||
store = pin_setup["store"]
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
# Create a locked PIN and an unlocked one
|
||||
data1 = PinCreate(name="Locked", pin="1111")
|
||||
pin1 = self.service.create_pin(db, program.id, store.id, data1)
|
||||
pin1.locked_until = datetime.now(UTC) + timedelta(minutes=30)
|
||||
pin1.failed_attempts = 5
|
||||
db.commit()
|
||||
|
||||
data2 = PinCreate(name="Active", pin="2222")
|
||||
self.service.create_pin(db, program.id, store.id, data2)
|
||||
|
||||
# Should find the active PIN
|
||||
result = self.service.verify_pin(db, program.id, "2222", store_id=store.id)
|
||||
assert result.name == "Active"
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
"""Unit tests for PointsService."""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.loyalty.exceptions import (
|
||||
InsufficientPointsException,
|
||||
InvalidRewardException,
|
||||
)
|
||||
from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram, LoyaltyTransaction
|
||||
from app.modules.loyalty.models.loyalty_program import LoyaltyType
|
||||
from app.modules.loyalty.models.loyalty_transaction import TransactionType
|
||||
from app.modules.loyalty.services.points_service import PointsService
|
||||
from app.modules.tenancy.models import Merchant, Store, User
|
||||
from app.modules.tenancy.models.store import StoreUser
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -16,3 +28,347 @@ class TestPointsService:
|
||||
def test_service_instantiation(self):
|
||||
"""Service can be instantiated."""
|
||||
assert self.service is not None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Fixtures
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def points_setup(db):
|
||||
"""Create a full setup for points tests."""
|
||||
from app.modules.customers.models.customer import Customer
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
|
||||
owner = User(
|
||||
email=f"ptsowner_{uid}@test.com",
|
||||
username=f"ptsowner_{uid}",
|
||||
hashed_password=auth.hash_password("testpass"),
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
db.add(owner)
|
||||
db.commit()
|
||||
db.refresh(owner)
|
||||
|
||||
merchant = Merchant(
|
||||
name=f"Points Merchant {uid}",
|
||||
owner_user_id=owner.id,
|
||||
contact_email=owner.email,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(merchant)
|
||||
db.commit()
|
||||
db.refresh(merchant)
|
||||
|
||||
store = Store(
|
||||
merchant_id=merchant.id,
|
||||
store_code=f"PTS_{uid.upper()}",
|
||||
subdomain=f"pts{uid}",
|
||||
name=f"Points Store {uid}",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store)
|
||||
db.commit()
|
||||
db.refresh(store)
|
||||
|
||||
store_user = StoreUser(store_id=store.id, user_id=owner.id, is_active=True)
|
||||
db.add(store_user)
|
||||
db.commit()
|
||||
|
||||
customer = Customer(
|
||||
email=f"ptscust_{uid}@test.com",
|
||||
first_name="Points",
|
||||
last_name="Customer",
|
||||
hashed_password="!unused!", # noqa: SEC001
|
||||
customer_number=f"PC-{uid.upper()}",
|
||||
store_id=store.id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(customer)
|
||||
db.commit()
|
||||
db.refresh(customer)
|
||||
|
||||
program = LoyaltyProgram(
|
||||
merchant_id=merchant.id,
|
||||
loyalty_type=LoyaltyType.POINTS.value,
|
||||
points_per_euro=10,
|
||||
welcome_bonus_points=0,
|
||||
minimum_redemption_points=50,
|
||||
minimum_purchase_cents=100,
|
||||
cooldown_minutes=0,
|
||||
max_daily_stamps=10,
|
||||
require_staff_pin=False,
|
||||
card_name="Points Card",
|
||||
card_color="#0000FF",
|
||||
is_active=True,
|
||||
points_rewards=[
|
||||
{"id": "r1", "name": "5 EUR off", "points_required": 100, "is_active": True},
|
||||
{"id": "r2", "name": "10 EUR off", "points_required": 200, "is_active": True},
|
||||
{"id": "r3", "name": "Inactive Reward", "points_required": 50, "is_active": False},
|
||||
],
|
||||
)
|
||||
db.add(program)
|
||||
db.commit()
|
||||
db.refresh(program)
|
||||
|
||||
card = LoyaltyCard(
|
||||
merchant_id=merchant.id,
|
||||
program_id=program.id,
|
||||
customer_id=customer.id,
|
||||
enrolled_at_store_id=store.id,
|
||||
card_number=f"PTSCARD-{uid.upper()}",
|
||||
stamp_count=0,
|
||||
total_stamps_earned=0,
|
||||
stamps_redeemed=0,
|
||||
points_balance=500,
|
||||
total_points_earned=500,
|
||||
points_redeemed=0,
|
||||
is_active=True,
|
||||
last_activity_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(card)
|
||||
db.commit()
|
||||
db.refresh(card)
|
||||
|
||||
return {
|
||||
"merchant": merchant,
|
||||
"store": store,
|
||||
"customer": customer,
|
||||
"program": program,
|
||||
"card": card,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Earn Points Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestEarnPoints:
|
||||
"""Tests for earn_points."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = PointsService()
|
||||
|
||||
def test_earn_points_calculation(self, db, points_setup):
|
||||
"""Points calculated correctly from purchase amount."""
|
||||
card = points_setup["card"]
|
||||
store = points_setup["store"]
|
||||
|
||||
result = self.service.earn_points(
|
||||
db,
|
||||
store_id=store.id,
|
||||
card_id=card.id,
|
||||
purchase_amount_cents=2000, # 20 EUR
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["points_earned"] == 200 # 20 EUR * 10 pts/EUR
|
||||
assert result["points_balance"] == 700 # 500 + 200
|
||||
|
||||
def test_earn_points_minimum_purchase(self, db, points_setup):
|
||||
"""Below minimum purchase returns 0 points."""
|
||||
card = points_setup["card"]
|
||||
store = points_setup["store"]
|
||||
|
||||
result = self.service.earn_points(
|
||||
db,
|
||||
store_id=store.id,
|
||||
card_id=card.id,
|
||||
purchase_amount_cents=50, # Below min of 100
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["points_earned"] == 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Redeem Points Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestRedeemPoints:
|
||||
"""Tests for redeem_points."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = PointsService()
|
||||
|
||||
def test_redeem_points_success(self, db, points_setup):
|
||||
"""Successfully redeem points for a reward."""
|
||||
card = points_setup["card"]
|
||||
store = points_setup["store"]
|
||||
|
||||
result = self.service.redeem_points(
|
||||
db,
|
||||
store_id=store.id,
|
||||
card_id=card.id,
|
||||
reward_id="r1",
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["points_spent"] == 100
|
||||
assert result["points_balance"] == 400 # 500 - 100
|
||||
|
||||
def test_redeem_points_insufficient(self, db, points_setup):
|
||||
"""Redeeming without enough points raises exception."""
|
||||
card = points_setup["card"]
|
||||
store = points_setup["store"]
|
||||
|
||||
# Set balance low
|
||||
card.points_balance = 50
|
||||
db.commit()
|
||||
|
||||
with pytest.raises(InsufficientPointsException):
|
||||
self.service.redeem_points(
|
||||
db,
|
||||
store_id=store.id,
|
||||
card_id=card.id,
|
||||
reward_id="r1", # needs 100
|
||||
)
|
||||
|
||||
def test_redeem_inactive_reward(self, db, points_setup):
|
||||
"""Redeeming an inactive reward raises exception."""
|
||||
card = points_setup["card"]
|
||||
store = points_setup["store"]
|
||||
|
||||
with pytest.raises(InvalidRewardException):
|
||||
self.service.redeem_points(
|
||||
db,
|
||||
store_id=store.id,
|
||||
card_id=card.id,
|
||||
reward_id="r3", # inactive
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Void Points Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestVoidPoints:
|
||||
"""Tests for void_points."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = PointsService()
|
||||
|
||||
def test_void_by_transaction(self, db, points_setup):
|
||||
"""Void points by original transaction ID."""
|
||||
card = points_setup["card"]
|
||||
store = points_setup["store"]
|
||||
|
||||
# Earn some points
|
||||
earn_result = self.service.earn_points(
|
||||
db, store_id=store.id, card_id=card.id, purchase_amount_cents=1000,
|
||||
)
|
||||
assert earn_result["points_earned"] == 100
|
||||
|
||||
# Find the earn transaction
|
||||
tx = (
|
||||
db.query(LoyaltyTransaction)
|
||||
.filter(
|
||||
LoyaltyTransaction.card_id == card.id,
|
||||
LoyaltyTransaction.transaction_type == TransactionType.POINTS_EARNED.value,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
void_result = self.service.void_points(
|
||||
db, store_id=store.id, card_id=card.id, original_transaction_id=tx.id,
|
||||
)
|
||||
|
||||
assert void_result["success"] is True
|
||||
assert void_result["points_voided"] == 100
|
||||
|
||||
def test_void_by_order_reference(self, db, points_setup):
|
||||
"""Void points by order reference."""
|
||||
card = points_setup["card"]
|
||||
store = points_setup["store"]
|
||||
|
||||
# Earn with order reference
|
||||
self.service.earn_points(
|
||||
db,
|
||||
store_id=store.id,
|
||||
card_id=card.id,
|
||||
purchase_amount_cents=2000,
|
||||
order_reference="ORDER-VOID-TEST",
|
||||
)
|
||||
|
||||
void_result = self.service.void_points(
|
||||
db,
|
||||
store_id=store.id,
|
||||
card_id=card.id,
|
||||
order_reference="ORDER-VOID-TEST",
|
||||
)
|
||||
|
||||
assert void_result["success"] is True
|
||||
assert void_result["points_voided"] == 200
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Adjust Points Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestAdjustPoints:
|
||||
"""Tests for adjust_points."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = PointsService()
|
||||
|
||||
def test_adjust_positive(self, db, points_setup):
|
||||
"""Add points via adjustment."""
|
||||
card = points_setup["card"]
|
||||
|
||||
result = self.service.adjust_points(
|
||||
db,
|
||||
card_id=card.id,
|
||||
points_delta=50,
|
||||
reason="Goodwill bonus",
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["points_balance"] == 550 # 500 + 50
|
||||
|
||||
def test_adjust_negative(self, db, points_setup):
|
||||
"""Remove points via adjustment."""
|
||||
card = points_setup["card"]
|
||||
|
||||
result = self.service.adjust_points(
|
||||
db,
|
||||
card_id=card.id,
|
||||
points_delta=-100,
|
||||
reason="Correction",
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["points_balance"] == 400 # 500 - 100
|
||||
|
||||
def test_adjust_floor_at_zero(self, db, points_setup):
|
||||
"""Negative adjustment doesn't go below zero."""
|
||||
card = points_setup["card"]
|
||||
|
||||
result = self.service.adjust_points(
|
||||
db,
|
||||
card_id=card.id,
|
||||
points_delta=-9999,
|
||||
reason="Full correction",
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["points_balance"] == 0
|
||||
|
||||
@@ -352,3 +352,98 @@ class TestDeleteProgram:
|
||||
"""Deleting non-existent program raises exception."""
|
||||
with pytest.raises(LoyaltyProgramNotFoundException):
|
||||
self.service.delete_program(db, 999999)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Stats
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestGetProgramStats:
|
||||
"""Tests for get_program_stats."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = ProgramService()
|
||||
|
||||
def test_stats_returns_all_fields(self, db, ps_program):
|
||||
"""Stats response includes all required fields."""
|
||||
stats = self.service.get_program_stats(db, ps_program.id)
|
||||
|
||||
assert "total_cards" in stats
|
||||
assert "active_cards" in stats
|
||||
assert "new_this_month" in stats
|
||||
assert "total_points_balance" in stats
|
||||
assert "avg_points_per_member" in stats
|
||||
assert "transactions_30d" in stats
|
||||
assert "points_issued_30d" in stats
|
||||
assert "points_redeemed_30d" in stats
|
||||
assert "points_this_month" in stats
|
||||
assert "points_redeemed_this_month" in stats
|
||||
assert "estimated_liability_cents" in stats
|
||||
|
||||
def test_stats_empty_program(self, db, ps_program):
|
||||
"""Stats for program with no cards."""
|
||||
stats = self.service.get_program_stats(db, ps_program.id)
|
||||
|
||||
assert stats["total_cards"] == 0
|
||||
assert stats["active_cards"] == 0
|
||||
assert stats["new_this_month"] == 0
|
||||
assert stats["total_points_balance"] == 0
|
||||
assert stats["avg_points_per_member"] == 0
|
||||
|
||||
def test_stats_with_cards(self, db, ps_program, ps_merchant):
|
||||
"""Stats reflect actual card data."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.modules.customers.models.customer import Customer
|
||||
from app.modules.loyalty.models import LoyaltyCard
|
||||
from app.modules.tenancy.models import Store
|
||||
|
||||
uid_store = uuid.uuid4().hex[:8]
|
||||
store = Store(
|
||||
merchant_id=ps_merchant.id,
|
||||
store_code=f"STAT_{uid_store.upper()}",
|
||||
subdomain=f"stat{uid_store}",
|
||||
name=f"Stats Store {uid_store}",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store)
|
||||
db.flush()
|
||||
|
||||
# Create cards with customers
|
||||
for i in range(3):
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
customer = Customer(
|
||||
email=f"stat_{uid}@test.com",
|
||||
first_name="Stat",
|
||||
last_name=f"Customer{i}",
|
||||
hashed_password="!unused!", # noqa: SEC001
|
||||
customer_number=f"SC-{uid.upper()}",
|
||||
store_id=store.id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(customer)
|
||||
db.flush()
|
||||
|
||||
card = LoyaltyCard(
|
||||
merchant_id=ps_merchant.id,
|
||||
program_id=ps_program.id,
|
||||
customer_id=customer.id,
|
||||
card_number=f"STAT-{i}-{uuid.uuid4().hex[:6]}",
|
||||
points_balance=100 * (i + 1),
|
||||
total_points_earned=100 * (i + 1),
|
||||
is_active=True,
|
||||
last_activity_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(card)
|
||||
db.commit()
|
||||
|
||||
stats = self.service.get_program_stats(db, ps_program.id)
|
||||
|
||||
assert stats["total_cards"] == 3
|
||||
assert stats["active_cards"] == 3
|
||||
assert stats["total_points_balance"] == 600 # 100+200+300
|
||||
assert stats["avg_points_per_member"] == 200.0 # 600/3
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
"""Unit tests for StampService."""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.loyalty.exceptions import (
|
||||
DailyStampLimitException,
|
||||
InsufficientStampsException,
|
||||
StampCooldownException,
|
||||
)
|
||||
from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram, LoyaltyTransaction
|
||||
from app.modules.loyalty.models.loyalty_program import LoyaltyType
|
||||
from app.modules.loyalty.models.loyalty_transaction import TransactionType
|
||||
from app.modules.loyalty.services.stamp_service import StampService
|
||||
from app.modules.tenancy.models import Merchant, Store, User
|
||||
from app.modules.tenancy.models.store import StoreUser
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -16,3 +29,259 @@ class TestStampService:
|
||||
def test_service_instantiation(self):
|
||||
"""Service can be instantiated."""
|
||||
assert self.service is not None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Fixtures
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stamp_setup(db):
|
||||
"""Create a full setup for stamp tests with stamps-type program."""
|
||||
from app.modules.customers.models.customer import Customer
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
|
||||
owner = User(
|
||||
email=f"stampowner_{uid}@test.com",
|
||||
username=f"stampowner_{uid}",
|
||||
hashed_password=auth.hash_password("testpass"),
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
db.add(owner)
|
||||
db.commit()
|
||||
db.refresh(owner)
|
||||
|
||||
merchant = Merchant(
|
||||
name=f"Stamp Merchant {uid}",
|
||||
owner_user_id=owner.id,
|
||||
contact_email=owner.email,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(merchant)
|
||||
db.commit()
|
||||
db.refresh(merchant)
|
||||
|
||||
store = Store(
|
||||
merchant_id=merchant.id,
|
||||
store_code=f"STAMP_{uid.upper()}",
|
||||
subdomain=f"stamp{uid}",
|
||||
name=f"Stamp Store {uid}",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store)
|
||||
db.commit()
|
||||
db.refresh(store)
|
||||
|
||||
store_user = StoreUser(store_id=store.id, user_id=owner.id, is_active=True)
|
||||
db.add(store_user)
|
||||
db.commit()
|
||||
|
||||
customer = Customer(
|
||||
email=f"stampcust_{uid}@test.com",
|
||||
first_name="Stamp",
|
||||
last_name="Customer",
|
||||
hashed_password="!unused!", # noqa: SEC001
|
||||
customer_number=f"SC-{uid.upper()}",
|
||||
store_id=store.id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(customer)
|
||||
db.commit()
|
||||
db.refresh(customer)
|
||||
|
||||
program = LoyaltyProgram(
|
||||
merchant_id=merchant.id,
|
||||
loyalty_type=LoyaltyType.STAMPS.value,
|
||||
stamps_target=5,
|
||||
stamps_reward_description="Free coffee",
|
||||
stamps_reward_value_cents=500,
|
||||
cooldown_minutes=0,
|
||||
max_daily_stamps=10,
|
||||
require_staff_pin=False,
|
||||
card_name="Stamp Card",
|
||||
card_color="#FF0000",
|
||||
is_active=True,
|
||||
points_per_euro=1,
|
||||
)
|
||||
db.add(program)
|
||||
db.commit()
|
||||
db.refresh(program)
|
||||
|
||||
card = LoyaltyCard(
|
||||
merchant_id=merchant.id,
|
||||
program_id=program.id,
|
||||
customer_id=customer.id,
|
||||
enrolled_at_store_id=store.id,
|
||||
card_number=f"STAMPCARD-{uid.upper()}",
|
||||
stamp_count=0,
|
||||
total_stamps_earned=0,
|
||||
stamps_redeemed=0,
|
||||
points_balance=0,
|
||||
total_points_earned=0,
|
||||
points_redeemed=0,
|
||||
is_active=True,
|
||||
last_activity_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(card)
|
||||
db.commit()
|
||||
db.refresh(card)
|
||||
|
||||
return {
|
||||
"merchant": merchant,
|
||||
"store": store,
|
||||
"customer": customer,
|
||||
"program": program,
|
||||
"card": card,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Add Stamp Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestAddStamp:
|
||||
"""Tests for add_stamp."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = StampService()
|
||||
|
||||
def test_add_stamp_success(self, db, stamp_setup):
|
||||
"""Successfully add a stamp to a card."""
|
||||
card = stamp_setup["card"]
|
||||
store = stamp_setup["store"]
|
||||
|
||||
result = self.service.add_stamp(db, store_id=store.id, card_id=card.id)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["stamp_count"] == 1
|
||||
assert result["stamps_target"] == 5
|
||||
assert result["stamps_until_reward"] == 4
|
||||
|
||||
def test_add_stamp_cooldown_violation(self, db, stamp_setup):
|
||||
"""Stamp within cooldown period raises exception."""
|
||||
card = stamp_setup["card"]
|
||||
store = stamp_setup["store"]
|
||||
program = stamp_setup["program"]
|
||||
|
||||
# Set cooldown
|
||||
program.cooldown_minutes = 15
|
||||
db.commit()
|
||||
|
||||
# Add first stamp
|
||||
self.service.add_stamp(db, store_id=store.id, card_id=card.id)
|
||||
|
||||
# Second stamp should fail (cooldown)
|
||||
with pytest.raises(StampCooldownException):
|
||||
self.service.add_stamp(db, store_id=store.id, card_id=card.id)
|
||||
|
||||
def test_add_stamp_daily_limit(self, db, stamp_setup):
|
||||
"""Exceeding daily stamp limit raises exception."""
|
||||
card = stamp_setup["card"]
|
||||
store = stamp_setup["store"]
|
||||
program = stamp_setup["program"]
|
||||
|
||||
# Set max 2 daily stamps
|
||||
program.max_daily_stamps = 2
|
||||
db.commit()
|
||||
|
||||
# Add 2 stamps
|
||||
self.service.add_stamp(db, store_id=store.id, card_id=card.id)
|
||||
self.service.add_stamp(db, store_id=store.id, card_id=card.id)
|
||||
|
||||
# Third should fail
|
||||
with pytest.raises(DailyStampLimitException):
|
||||
self.service.add_stamp(db, store_id=store.id, card_id=card.id)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Redeem Stamps Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestRedeemStamps:
|
||||
"""Tests for redeem_stamps."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = StampService()
|
||||
|
||||
def test_redeem_stamps_success(self, db, stamp_setup):
|
||||
"""Successfully redeem stamps for a reward."""
|
||||
card = stamp_setup["card"]
|
||||
store = stamp_setup["store"]
|
||||
program = stamp_setup["program"]
|
||||
|
||||
# Give enough stamps
|
||||
card.stamp_count = program.stamps_target
|
||||
card.total_stamps_earned = program.stamps_target
|
||||
db.commit()
|
||||
|
||||
result = self.service.redeem_stamps(db, store_id=store.id, card_id=card.id)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["stamp_count"] == 0
|
||||
assert result["reward_description"] == "Free coffee"
|
||||
|
||||
def test_redeem_stamps_insufficient(self, db, stamp_setup):
|
||||
"""Redeeming without enough stamps raises exception."""
|
||||
card = stamp_setup["card"]
|
||||
store = stamp_setup["store"]
|
||||
|
||||
# Card has 0 stamps, needs 5
|
||||
with pytest.raises(InsufficientStampsException):
|
||||
self.service.redeem_stamps(db, store_id=store.id, card_id=card.id)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Void Stamps Tests
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestVoidStamps:
|
||||
"""Tests for void_stamps."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = StampService()
|
||||
|
||||
def test_void_stamps_by_transaction(self, db, stamp_setup):
|
||||
"""Void stamps by original transaction ID."""
|
||||
card = stamp_setup["card"]
|
||||
store = stamp_setup["store"]
|
||||
|
||||
# Add a stamp first
|
||||
result = self.service.add_stamp(db, store_id=store.id, card_id=card.id)
|
||||
assert result["stamp_count"] == 1
|
||||
|
||||
# Find the transaction
|
||||
tx = (
|
||||
db.query(LoyaltyTransaction)
|
||||
.filter(
|
||||
LoyaltyTransaction.card_id == card.id,
|
||||
LoyaltyTransaction.transaction_type == TransactionType.STAMP_EARNED.value,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
void_result = self.service.void_stamps(
|
||||
db,
|
||||
store_id=store.id,
|
||||
card_id=card.id,
|
||||
original_transaction_id=tx.id,
|
||||
)
|
||||
|
||||
assert void_result["success"] is True
|
||||
assert void_result["stamp_count"] == 0
|
||||
|
||||
Reference in New Issue
Block a user