feat: add SQL query tool, platform debug, loyalty settings, and multi-module improvements
Some checks failed
CI / ruff (push) Successful in 14s
CI / pytest (push) Failing after 50m12s
CI / validate (push) Successful in 25s
CI / dependency-scanning (push) Successful in 32s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped

- 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:
2026-03-10 20:08:07 +01:00
parent a77a8a3a98
commit 319900623a
77 changed files with 5341 additions and 401 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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("#")

View File

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

View File

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

View File

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

View File

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

View File

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

View 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');

View File

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

View File

@@ -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 });

View File

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

View 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 %}

View File

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

View File

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

View File

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

View 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"

View File

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

View File

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

View File

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