Compare commits

..

4 Commits

Author SHA1 Message Date
29d942322d feat(loyalty): make logo URL mandatory on program edit forms
Some checks failed
CI / ruff (push) Successful in 10s
CI / pytest (push) Failing after 49m23s
CI / validate (push) Successful in 26s
CI / dependency-scanning (push) Successful in 30s
CI / docs (push) Has been skipped
CI / deploy (push) Has been skipped
Logo URL is required by Google Wallet API for LoyaltyClass creation.
Added validation across all three program edit screens (admin, merchant, store)
with a helpful hint explaining the requirement.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:08:38 +01:00
8c8975239a feat(loyalty): fix Google Wallet integration and improve enrollment flow
- Fix Google Wallet class creation: add required issuerName field (merchant name),
  programLogo with default logo fallback, hexBackgroundColor default
- Add default loyalty logo assets (200px + 512px) for programs without custom logos
- Smart retry: skip retries on 400/401/403/404 client errors (not transient)
- Fix enrollment success page: use sessionStorage for wallet URLs instead of
  authenticated API call (self-enrolled customers have no session)
- Hide wallet section on success page when no wallet URLs available
- Wire up T&C modal on enrollment page with program.terms_text
- Add startup validation for Google/Apple Wallet configs in lifespan
- Add admin wallet status dashboard endpoint and UI (moved to service layer)
- Fix Apple Wallet push notifications with real APNs HTTP/2 implementation
- Fix docs: correct enrollment URLs (port, path segments, /v1 prefix)
- Fix test assertion: !loyalty-enroll! → !enrollment!

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 17:32:55 +01:00
f766a72480 feat: enable dev debug toolbar on admin, merchant, and storefront panels
The toolbar was only included in the store base template. Add it to all
frontends so developers can use Ctrl+Alt+D everywhere in dev.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 15:55:29 +01:00
618376aa39 feat(dev_tools): add diagnostics hub with permissions audit tool
Evolve the platform-debug page into a diagnostics hub with sidebar
explorer layout. Add permissions audit API that introspects all
registered page routes and reports auth/permission enforcement status.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 15:44:49 +01:00
33 changed files with 1535 additions and 362 deletions

View File

@@ -224,6 +224,7 @@ R2_BACKUP_BUCKET=orion-backups
# Get Issuer ID from https://pay.google.com/business/console # Get Issuer ID from https://pay.google.com/business/console
# LOYALTY_GOOGLE_ISSUER_ID=3388000000012345678 # LOYALTY_GOOGLE_ISSUER_ID=3388000000012345678
# LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=/path/to/service-account.json # LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=/path/to/service-account.json
# LOYALTY_DEFAULT_LOGO_URL=https://yourdomain.com/path/to/default-logo.png
# Apple Wallet integration (requires Apple Developer account) # Apple Wallet integration (requires Apple Developer account)
# LOYALTY_APPLE_PASS_TYPE_ID=pass.com.example.loyalty # LOYALTY_APPLE_PASS_TYPE_ID=pass.com.example.loyalty

View File

@@ -222,6 +222,17 @@ class Settings(BaseSettings):
# ============================================================================= # =============================================================================
loyalty_google_issuer_id: str | None = None loyalty_google_issuer_id: str | None = None
loyalty_google_service_account_json: str | None = None # Path to service account JSON loyalty_google_service_account_json: str | None = None # Path to service account JSON
loyalty_google_wallet_origins: list[str] = [] # Allowed origins for save-to-wallet JWT
loyalty_default_logo_url: str = "https://rewardflow.lu/static/modules/loyalty/shared/img/default-logo-200.png"
# =============================================================================
# APPLE WALLET (LOYALTY MODULE)
# =============================================================================
loyalty_apple_pass_type_id: str | None = None
loyalty_apple_team_id: str | None = None
loyalty_apple_wwdr_cert_path: str | None = None
loyalty_apple_signer_cert_path: str | None = None
loyalty_apple_signer_key_path: str | None = None
model_config = {"env_file": ".env"} model_config = {"env_file": ".env"}

View File

@@ -44,6 +44,9 @@ async def lifespan(app: FastAPI):
grafana_url=settings.grafana_url, grafana_url=settings.grafana_url,
) )
# Validate wallet configurations
_validate_wallet_config()
logger.info("[OK] Application startup completed") logger.info("[OK] Application startup completed")
yield yield
@@ -53,6 +56,72 @@ async def lifespan(app: FastAPI):
shutdown_observability() shutdown_observability()
def _validate_wallet_config():
"""Validate Google/Apple Wallet configuration at startup."""
try:
from app.modules.loyalty.services.google_wallet_service import (
google_wallet_service,
)
result = google_wallet_service.validate_config()
if result["configured"]:
if result["credentials_valid"]:
logger.info(
"[OK] Google Wallet configured (issuer: %s, email: %s)",
result["issuer_id"],
result.get("service_account_email", "unknown"),
)
else:
for err in result["errors"]:
logger.error("[FAIL] Google Wallet config error: %s", err)
else:
logger.info("[--] Google Wallet not configured (optional)")
# Apple Wallet config check
if settings.loyalty_apple_pass_type_id:
import os
missing = []
for field in [
"loyalty_apple_team_id",
"loyalty_apple_wwdr_cert_path",
"loyalty_apple_signer_cert_path",
"loyalty_apple_signer_key_path",
]:
val = getattr(settings, field, None)
if not val:
missing.append(field)
elif field.endswith("_path") and not os.path.isfile(val):
logger.error(
"[FAIL] Apple Wallet file not found: %s = %s",
field,
val,
)
if missing:
logger.error(
"[FAIL] Apple Wallet missing config: %s",
", ".join(missing),
)
elif not any(
not os.path.isfile(getattr(settings, f, "") or "")
for f in [
"loyalty_apple_wwdr_cert_path",
"loyalty_apple_signer_cert_path",
"loyalty_apple_signer_key_path",
]
):
logger.info(
"[OK] Apple Wallet configured (pass type: %s)",
settings.loyalty_apple_pass_type_id,
)
else:
logger.info("[--] Apple Wallet not configured (optional)")
except Exception as exc: # noqa: BLE001
logger.warning("Wallet config validation skipped: %s", exc)
# === NEW HELPER FUNCTION === # === NEW HELPER FUNCTION ===
def check_database_ready(): def check_database_ready():
"""Check if database is ready (migrations have been run).""" """Check if database is ready (migrations have been run)."""

View File

@@ -82,7 +82,7 @@ dev_tools_module = ModuleDefinition(
), ),
MenuItemDefinition( MenuItemDefinition(
id="platform-debug", id="platform-debug",
label_key="dev_tools.menu.platform_debug", label_key="dev_tools.menu.diagnostics",
icon="search", icon="search",
route="/admin/platform-debug", route="/admin/platform-debug",
order=40, order=40,

View File

@@ -14,9 +14,20 @@
"components": "Komponenten", "components": "Komponenten",
"icons": "Icons", "icons": "Icons",
"sql_query": "SQL Abfrage", "sql_query": "SQL Abfrage",
"platform_debug": "Plattform Debug", "diagnostics": "Diagnostik",
"translation_editor": "Übersetzungseditor" "translation_editor": "Übersetzungseditor"
}, },
"diagnostics": {
"title": "Diagnostik",
"platform_trace": "Plattform-Trace",
"permissions_audit": "Berechtigungsprüfung",
"run_audit": "Prüfung starten",
"status_ok": "OK",
"status_warning": "Warnung",
"status_error": "Fehler",
"total_routes": "Routen gesamt",
"no_routes_match": "Keine Routen entsprechen Ihren Filtern"
},
"translation_editor": { "translation_editor": {
"title": "Übersetzungseditor", "title": "Übersetzungseditor",
"all_modules": "Alle Module", "all_modules": "Alle Module",

View File

@@ -14,9 +14,20 @@
"components": "Components", "components": "Components",
"icons": "Icons", "icons": "Icons",
"sql_query": "SQL Query", "sql_query": "SQL Query",
"platform_debug": "Platform Debug", "diagnostics": "Diagnostics",
"translation_editor": "Translation Editor" "translation_editor": "Translation Editor"
}, },
"diagnostics": {
"title": "Diagnostics",
"platform_trace": "Platform Trace",
"permissions_audit": "Permissions Audit",
"run_audit": "Run Audit",
"status_ok": "OK",
"status_warning": "Warning",
"status_error": "Error",
"total_routes": "Total Routes",
"no_routes_match": "No routes match your filters"
},
"translation_editor": { "translation_editor": {
"title": "Translation Editor", "title": "Translation Editor",
"all_modules": "All Modules", "all_modules": "All Modules",

View File

@@ -14,9 +14,20 @@
"components": "Composants", "components": "Composants",
"icons": "Icônes", "icons": "Icônes",
"sql_query": "Requête SQL", "sql_query": "Requête SQL",
"platform_debug": "Debug Plateforme", "diagnostics": "Diagnostics",
"translation_editor": "Éditeur de traductions" "translation_editor": "Éditeur de traductions"
}, },
"diagnostics": {
"title": "Diagnostics",
"platform_trace": "Trace Plateforme",
"permissions_audit": "Audit des Permissions",
"run_audit": "Lancer l'audit",
"status_ok": "OK",
"status_warning": "Avertissement",
"status_error": "Erreur",
"total_routes": "Routes totales",
"no_routes_match": "Aucune route ne correspond à vos filtres"
},
"translation_editor": { "translation_editor": {
"title": "Éditeur de traductions", "title": "Éditeur de traductions",
"all_modules": "Tous les modules", "all_modules": "Tous les modules",

View File

@@ -14,9 +14,20 @@
"components": "Komponenten", "components": "Komponenten",
"icons": "Icons", "icons": "Icons",
"sql_query": "SQL Ufro", "sql_query": "SQL Ufro",
"platform_debug": "Plattform Debug", "diagnostics": "Diagnostik",
"translation_editor": "Iwwersetzungseditor" "translation_editor": "Iwwersetzungseditor"
}, },
"diagnostics": {
"title": "Diagnostik",
"platform_trace": "Plattform-Trace",
"permissions_audit": "Berechtigungsprüfung",
"run_audit": "Prüfung starten",
"status_ok": "OK",
"status_warning": "Warnung",
"status_error": "Feeler",
"total_routes": "Routen gesamt",
"no_routes_match": "Keng Routen entspriechen Ären Filteren"
},
"translation_editor": { "translation_editor": {
"title": "Iwwersetzungseditor", "title": "Iwwersetzungseditor",
"all_modules": "All Moduler", "all_modules": "All Moduler",

View File

@@ -7,6 +7,9 @@ Aggregates all admin API routers for the dev-tools module.
from fastapi import APIRouter from fastapi import APIRouter
from app.modules.dev_tools.routes.api.admin_diagnostics import (
router as diagnostics_router,
)
from app.modules.dev_tools.routes.api.admin_platform_debug import ( from app.modules.dev_tools.routes.api.admin_platform_debug import (
router as platform_debug_router, router as platform_debug_router,
) )
@@ -19,3 +22,4 @@ router = APIRouter()
router.include_router(sql_query_router, tags=["sql-query"]) router.include_router(sql_query_router, tags=["sql-query"])
router.include_router(platform_debug_router, tags=["platform-debug"]) router.include_router(platform_debug_router, tags=["platform-debug"])
router.include_router(translations_router, tags=["translations"]) router.include_router(translations_router, tags=["translations"])
router.include_router(diagnostics_router, tags=["diagnostics"])

View File

@@ -0,0 +1,289 @@
# app/modules/dev_tools/routes/api/admin_diagnostics.py
"""
Diagnostics API endpoints.
Provides route introspection and permissions audit capabilities
for the Diagnostics Hub admin page.
"""
import inspect
import logging
from fastapi import APIRouter, Depends, Request
from fastapi.routing import APIRoute
from pydantic import BaseModel
from app.api.deps import get_current_super_admin_api
from app.modules.tenancy.schemas.auth import UserContext
router = APIRouter(prefix="/diagnostics")
logger = logging.getLogger(__name__)
# ── Response schemas ──
class RouteAuditEntry(BaseModel):
path: str
methods: list[str]
module: str | None
frontend: str | None
endpoint_name: str
auth_dependency: str | None
auth_detail: str | None
status: str # "ok", "warning", "error"
issue: str | None
class FrontendSummary(BaseModel):
total: int = 0
ok: int = 0
warnings: int = 0
errors: int = 0
class AuditSummary(BaseModel):
total: int = 0
ok: int = 0
warnings: int = 0
errors: int = 0
by_frontend: dict[str, FrontendSummary] = {}
class PermissionsAuditResponse(BaseModel):
routes: list[RouteAuditEntry]
summary: AuditSummary
# ── Auth dependency names for classification ──
_OK_DEPENDENCIES = {
"require_menu_access",
"require_store_page_permission",
"get_current_super_admin",
"get_current_super_admin_api",
}
_ADMIN_AUTH_DEPENDENCIES = {
"get_current_admin_from_cookie_or_header",
}
_STORE_AUTH_DEPENDENCIES = {
"get_current_store_from_cookie_or_header",
}
_MERCHANT_AUTH_DEPENDENCIES = {
"get_current_merchant_from_cookie_or_header",
}
def _classify_frontend(path: str) -> str | None:
"""Classify frontend type from route path prefix."""
if path.startswith("/admin/"):
return "admin"
if path.startswith(("/store/", "/store/{")):
return "store"
if path.startswith(("/merchants/", "/merchants/{")):
return "merchant"
if path.startswith(("/storefront/", "/storefront/{")):
return "storefront"
return None
def _infer_module(path: str) -> str | None:
"""Infer module name from path segments."""
# Strip frontend prefix to get module segment
# e.g. /admin/loyalty/programs → loyalty
# e.g. /store/{store_code}/loyalty/terminal → loyalty
parts = path.strip("/").split("/")
if len(parts) < 2:
return None
# Skip the frontend prefix and optional path params
start = 1
# For store/merchant with {store_code}/{merchant_code} param
if len(parts) > 2 and parts[1].startswith("{"):
start = 2
if start < len(parts):
candidate = parts[start]
# Skip common non-module segments
if candidate not in {"dashboard", "settings", "profile", "login", "logout", "api"}:
return candidate
return None
def _extract_auth_dependencies(endpoint) -> list[dict]:
"""
Inspect endpoint function signature to find Depends() parameters
and extract auth-related dependency information.
"""
results = []
try:
sig = inspect.signature(endpoint)
except (ValueError, TypeError):
return results
for _param_name, param in sig.parameters.items():
default = param.default
if default is inspect.Parameter.empty:
continue
# Check for Depends() instances
dep_callable = None
if hasattr(default, "dependency"):
dep_callable = default.dependency
elif callable(default) and not isinstance(default, type):
continue # Skip plain callables that aren't Depends
if dep_callable is None:
continue
dep_name = None
dep_detail = None
# Direct function reference
if callable(dep_callable) and hasattr(dep_callable, "__name__"):
dep_name = dep_callable.__name__
# functools.partial (e.g. require_menu_access("id", FrontendType.ADMIN))
if hasattr(dep_callable, "func"):
dep_name = getattr(dep_callable.func, "__name__", str(dep_callable.func))
# Extract args for detail
if dep_callable.args:
dep_detail = str(dep_callable.args[0])
# Closure / nested function from factory
if dep_name is None and callable(dep_callable):
# Try qualname for closures from factories
qualname = getattr(dep_callable, "__qualname__", "")
if "." in qualname:
# e.g. "require_menu_access.<locals>.dependency"
factory_name = qualname.split(".")[0]
dep_name = factory_name
# Try to extract closure variables for detail
if hasattr(dep_callable, "__closure__") and dep_callable.__closure__:
for cell in dep_callable.__closure__:
try:
val = cell.cell_contents
if isinstance(val, str) and val not in ("admin", "store", "merchant"):
dep_detail = val
break
except ValueError:
continue
if dep_name:
results.append({"name": dep_name, "detail": dep_detail})
return results
def _classify_route(path: str, frontend: str | None, auth_deps: list[dict]) -> tuple[str, str | None, str | None, str | None]:
"""
Classify a route's auth status.
Returns: (status, auth_dependency, auth_detail, issue)
"""
if not auth_deps:
return "error", None, None, "No authentication dependency found"
# Check each dependency against classification rules
for dep in auth_deps:
name = dep["name"]
detail = dep["detail"]
if name in _OK_DEPENDENCIES:
return "ok", name, detail, None
if name in _ADMIN_AUTH_DEPENDENCIES and frontend == "admin":
return "ok", name, detail, None
# If we get here, check for auth-only deps (warnings)
for dep in auth_deps:
name = dep["name"]
detail = dep["detail"]
if name in _STORE_AUTH_DEPENDENCIES and frontend == "store":
return "warning", name, detail, "Authentication only \u2014 no menu access or permission check"
if name in _MERCHANT_AUTH_DEPENDENCIES and frontend == "merchant":
return "warning", name, detail, "Authentication only \u2014 no menu access check"
if name in _ADMIN_AUTH_DEPENDENCIES:
return "warning", name, detail, "Admin auth on non-admin route"
# Has some dependency but not recognized
first = auth_deps[0]
return "warning", first["name"], first["detail"], f"Unrecognized auth dependency: {first['name']}"
@router.get("/permissions-audit", response_model=PermissionsAuditResponse)
def permissions_audit(
request: Request,
current_user: UserContext = Depends(get_current_super_admin_api),
):
"""
Introspect all registered page routes and audit their auth/permission enforcement.
Examines each route's endpoint function signature to find Depends() parameters
and classify the level of authentication and authorization applied.
"""
audit_routes: list[RouteAuditEntry] = []
for route in request.app.routes:
if not isinstance(route, APIRoute):
continue
# Filter to page routes only (include_in_schema=False signals HTML page routes)
if route.include_in_schema:
continue
path = route.path
methods = sorted(route.methods - {"HEAD", "OPTIONS"}) if route.methods else []
frontend = _classify_frontend(path)
module = _infer_module(path)
endpoint_name = getattr(route.endpoint, "__name__", str(route.endpoint))
# Extract and classify auth dependencies
auth_deps = _extract_auth_dependencies(route.endpoint)
status, auth_dep, auth_detail, issue = _classify_route(path, frontend, auth_deps)
audit_routes.append(RouteAuditEntry(
path=path,
methods=methods,
module=module,
frontend=frontend,
endpoint_name=endpoint_name,
auth_dependency=auth_dep,
auth_detail=auth_detail,
status=status,
issue=issue,
))
# Sort by frontend, then path
audit_routes.sort(key=lambda r: (r.frontend or "", r.path))
# Build summary
summary = AuditSummary(
total=len(audit_routes),
ok=sum(1 for r in audit_routes if r.status == "ok"),
warnings=sum(1 for r in audit_routes if r.status == "warning"),
errors=sum(1 for r in audit_routes if r.status == "error"),
by_frontend={},
)
for r in audit_routes:
fe = r.frontend or "other"
if fe not in summary.by_frontend:
summary.by_frontend[fe] = FrontendSummary()
fe_summary = summary.by_frontend[fe]
fe_summary.total += 1
if r.status == "ok":
fe_summary.ok += 1
elif r.status == "warning":
fe_summary.warnings += 1
elif r.status == "error":
fe_summary.errors += 1
return PermissionsAuditResponse(routes=audit_routes, summary=summary)

View File

@@ -140,8 +140,8 @@ async def admin_platform_debug_page(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
""" """
Render platform resolution debug page. Render diagnostics hub page.
Traces middleware pipeline for all URL patterns. Provides platform trace, permissions audit, and other diagnostic tools.
""" """
return templates.TemplateResponse( return templates.TemplateResponse(
"dev_tools/admin/platform-debug.html", "dev_tools/admin/platform-debug.html",

View File

@@ -1,137 +1,341 @@
{# app/modules/dev_tools/templates/dev_tools/admin/platform-debug.html #} {# app/modules/dev_tools/templates/dev_tools/admin/platform-debug.html #}
{% extends "admin/base.html" %} {% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header, table_empty_state %}
{% block title %}Platform Resolution Debug{% endblock %} {% block title %}Diagnostics{% endblock %}
{% block alpine_data %}platformDebug(){% endblock %} {% block alpine_data %}platformDebug(){% endblock %}
{% block content %} {% block content %}
<div class="mb-6"> {{ page_header('Diagnostics', back_url='/admin/dashboard', back_label='Back to Dashboard') }}
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Platform Resolution Trace</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Simulates the middleware pipeline for each URL pattern to trace how platform &amp; store context are resolved.
</p>
</div>
<!-- Controls --> <div class="flex gap-6">
<div class="mb-6 p-4 bg-white dark:bg-gray-800 rounded-lg shadow-xs"> <!-- ═══════════════════════════════════════════════════════════════════ -->
<div class="flex items-center gap-4 flex-wrap"> <!-- Left sidebar — Diagnostic Tools explorer -->
<button @click="runAllTests()" :disabled="running" <!-- ═══════════════════════════════════════════════════════════════════ -->
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"> <div class="w-72 flex-shrink-0">
<span x-show="!running">Run All Tests</span> <div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<span x-show="running">Running...</span> <div class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-3 flex items-center gap-1.5">
</button> <span x-html="$icon('cog', 'w-4 h-4')"></span>
<div class="flex gap-2"> Diagnostic Tools
<button @click="filterGroup = 'all'" :class="filterGroup === 'all' ? 'bg-gray-200 dark:bg-gray-600' : ''" </div>
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">All</button> <template x-for="group in toolGroups" :key="group.category">
<button @click="filterGroup = 'dev'" :class="filterGroup === 'dev' ? 'bg-gray-200 dark:bg-gray-600' : ''" <div class="mb-1">
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">Dev Path-Based</button> <button @click="toggleCategory(group.category)"
<button @click="filterGroup = 'prod-domain'" :class="filterGroup === 'prod-domain' ? 'bg-gray-200 dark:bg-gray-600' : ''" class="flex items-center justify-between w-full text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase px-2 py-1 rounded hover:text-gray-600 dark:hover:text-gray-300 transition-colors">
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">Prod Domain</button> <span x-text="group.category"></span>
<button @click="filterGroup = 'prod-subdomain'" :class="filterGroup === 'prod-subdomain' ? 'bg-gray-200 dark:bg-gray-600' : ''" <span class="text-[10px] font-mono leading-none" x-text="isCategoryExpanded(group.category) ? '' : '+'"></span>
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">Prod Subdomain</button> </button>
<button @click="filterGroup = 'prod-custom'" :class="filterGroup === 'prod-custom' ? 'bg-gray-200 dark:bg-gray-600' : ''" <ul x-show="isCategoryExpanded(group.category)" x-collapse class="space-y-0.5 mt-0.5">
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">Prod Custom Domain</button> <template x-for="tool in group.items" :key="tool.id">
<li @click="selectTool(tool.id)"
class="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm cursor-pointer transition-colors"
:class="activeTool === tool.id
? 'bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-gray-200'">
<span x-html="$icon(tool.icon, 'w-3.5 h-3.5 flex-shrink-0')"></span>
<span class="truncate" x-text="tool.label"></span>
</li>
</template>
</ul>
</div>
</template>
</div> </div>
</div> </div>
</div>
<!-- Custom test --> <!-- ═══════════════════════════════════════════════════════════════════ -->
<div class="mb-6 p-4 bg-white dark:bg-gray-800 rounded-lg shadow-xs"> <!-- Main area — tool content panels -->
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Custom Test</h3> <!-- ═══════════════════════════════════════════════════════════════════ -->
<div class="flex gap-3 items-end flex-wrap"> <div class="flex-1 min-w-0">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Host</label>
<input x-model="customHost" type="text" placeholder="localhost:8000"
class="px-3 py-2 border rounded-lg text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white w-56">
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Path</label>
<input x-model="customPath" type="text" placeholder="/platforms/loyalty/store/WIZATECH/login"
class="px-3 py-2 border rounded-lg text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white w-80">
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Body platform_code</label>
<input x-model="customPlatformCode" type="text" placeholder="(optional)"
class="px-3 py-2 border rounded-lg text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white w-40">
</div>
<button @click="runCustomTest()" class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 text-sm">
Trace
</button>
</div>
<!-- Custom result -->
<template x-if="customResult">
<div class="mt-4">
<div x-html="renderTrace(customResult)"></div>
</div>
</template>
</div>
<!-- Test Results --> <!-- ─────────────────────────────────────────────────────────── -->
<div class="space-y-4"> <!-- Tool: Platform Trace (existing functionality, verbatim) -->
<template x-for="test in filteredTests" :key="test.id"> <!-- ─────────────────────────────────────────────────────────── -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs overflow-hidden"> <div x-show="activeTool === 'platform-trace'" x-cloak>
<!-- Header --> <div class="mb-6">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 cursor-pointer flex items-center justify-between" <h2 class="text-xl font-bold text-gray-900 dark:text-white">Platform Resolution Trace</h2>
@click="test.expanded = !test.expanded"> <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
<div class="flex items-center gap-3"> Simulates the middleware pipeline for each URL pattern to trace how platform &amp; store context are resolved.
<!-- Status indicator --> </p>
<div class="w-3 h-3 rounded-full" </div>
:class="{
'bg-gray-300 dark:bg-gray-600': !test.result, <!-- Controls -->
'bg-green-500': test.result && test.pass, <div class="mb-6 p-4 bg-white dark:bg-gray-800 rounded-lg shadow-xs">
'bg-red-500': test.result && !test.pass, <div class="flex items-center gap-4 flex-wrap">
'animate-pulse bg-yellow-400': test.running <button @click="runAllTests()" :disabled="running"
}"></div> class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50">
<span class="text-xs px-2 py-0.5 rounded-full font-medium" <span x-show="!running">Run All Tests</span>
:class="{ <span x-show="running">Running...</span>
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300': test.group === 'dev', </button>
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300': test.group === 'prod-domain', <div class="flex gap-2">
'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300': test.group === 'prod-subdomain', <button @click="filterGroup = 'all'" :class="filterGroup === 'all' ? 'bg-gray-200 dark:bg-gray-600' : ''"
'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300': test.group === 'prod-custom' class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">All</button>
}" x-text="test.groupLabel"></span> <button @click="filterGroup = 'dev'" :class="filterGroup === 'dev' ? 'bg-gray-200 dark:bg-gray-600' : ''"
<div> class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">Dev Path-Based</button>
<span class="font-mono text-sm text-gray-900 dark:text-white" x-text="test.label"></span> <button @click="filterGroup = 'prod-domain'" :class="filterGroup === 'prod-domain' ? 'bg-gray-200 dark:bg-gray-600' : ''"
<span class="text-xs text-gray-500 dark:text-gray-400 ml-2" x-text="test.description"></span> class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">Prod Domain</button>
<button @click="filterGroup = 'prod-subdomain'" :class="filterGroup === 'prod-subdomain' ? 'bg-gray-200 dark:bg-gray-600' : ''"
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">Prod Subdomain</button>
<button @click="filterGroup = 'prod-custom'" :class="filterGroup === 'prod-custom' ? 'bg-gray-200 dark:bg-gray-600' : ''"
class="px-3 py-1 text-sm rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 dark:text-gray-300">Prod Custom Domain</button>
</div> </div>
</div> </div>
<div class="flex items-center gap-3">
<template x-if="test.result">
<span class="text-xs font-mono px-2 py-1 rounded"
:class="test.pass ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' : 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300'"
x-text="'platform=' + (test.result.login_platform_code || 'null')"></span>
</template>
<template x-if="test.result">
<button @click.stop="copyTrace(test)" title="Copy full trace"
class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600">
Copy
</button>
</template>
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="test.expanded ? 'rotate-180' : ''"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
</div> </div>
<!-- Detail -->
<div x-show="test.expanded" x-cloak class="px-4 py-3"> <!-- Custom test -->
<template x-if="test.result"> <div class="mb-6 p-4 bg-white dark:bg-gray-800 rounded-lg shadow-xs">
<div x-html="renderTrace(test.result)"></div> <h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Custom Test</h3>
<div class="flex gap-3 items-end flex-wrap">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Host</label>
<input x-model="customHost" type="text" placeholder="localhost:8000"
class="px-3 py-2 border rounded-lg text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white w-56">
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Path</label>
<input x-model="customPath" type="text" placeholder="/platforms/loyalty/store/WIZATECH/login"
class="px-3 py-2 border rounded-lg text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white w-80">
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Body platform_code</label>
<input x-model="customPlatformCode" type="text" placeholder="(optional)"
class="px-3 py-2 border rounded-lg text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white w-40">
</div>
<button @click="runCustomTest()" class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 text-sm">
Trace
</button>
</div>
<!-- Custom result -->
<template x-if="customResult">
<div class="mt-4">
<div x-html="renderTrace(customResult)"></div>
</div>
</template> </template>
<template x-if="!test.result && !test.running"> </div>
<p class="text-sm text-gray-500 dark:text-gray-400">Click "Run All Tests" to trace this case.</p>
</template> <!-- Test Results -->
<template x-if="test.running"> <div class="space-y-4">
<p class="text-sm text-yellow-600 dark:text-yellow-400">Running...</p> <template x-for="test in filteredTests" :key="test.id">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs overflow-hidden">
<!-- Header -->
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 cursor-pointer flex items-center justify-between"
@click="test.expanded = !test.expanded">
<div class="flex items-center gap-3">
<!-- Status indicator -->
<div class="w-3 h-3 rounded-full"
:class="{
'bg-gray-300 dark:bg-gray-600': !test.result,
'bg-green-500': test.result && test.pass,
'bg-red-500': test.result && !test.pass,
'animate-pulse bg-yellow-400': test.running
}"></div>
<span class="text-xs px-2 py-0.5 rounded-full font-medium"
:class="{
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300': test.group === 'dev',
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300': test.group === 'prod-domain',
'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300': test.group === 'prod-subdomain',
'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300': test.group === 'prod-custom'
}" x-text="test.groupLabel"></span>
<div>
<span class="font-mono text-sm text-gray-900 dark:text-white" x-text="test.label"></span>
<span class="text-xs text-gray-500 dark:text-gray-400 ml-2" x-text="test.description"></span>
</div>
</div>
<div class="flex items-center gap-3">
<template x-if="test.result">
<span class="text-xs font-mono px-2 py-1 rounded"
:class="test.pass ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' : 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300'"
x-text="'platform=' + (test.result.login_platform_code || 'null')"></span>
</template>
<template x-if="test.result">
<button @click.stop="copyTrace(test)" title="Copy full trace"
class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600">
Copy
</button>
</template>
<svg class="w-4 h-4 text-gray-400 transition-transform" :class="test.expanded ? 'rotate-180' : ''"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
</div>
<!-- Detail -->
<div x-show="test.expanded" x-cloak class="px-4 py-3">
<template x-if="test.result">
<div x-html="renderTrace(test.result)"></div>
</template>
<template x-if="!test.result && !test.running">
<p class="text-sm text-gray-500 dark:text-gray-400">Click "Run All Tests" to trace this case.</p>
</template>
<template x-if="test.running">
<p class="text-sm text-yellow-600 dark:text-yellow-400">Running...</p>
</template>
</div>
</div>
</template> </template>
</div> </div>
</div> </div>
</template>
<!-- ─────────────────────────────────────────────────────────── -->
<!-- Tool: Permissions Audit -->
<!-- ─────────────────────────────────────────────────────────── -->
<div x-show="activeTool === 'permissions-audit'" x-cloak>
<div class="mb-6">
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Permissions Audit</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Introspects all registered page routes and reports which have proper auth/permission enforcement.
</p>
</div>
<!-- Run Audit button -->
<div class="mb-6">
<button @click="runPermissionsAudit()" :disabled="auditLoading"
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 text-sm font-medium">
<span x-show="!auditLoading">Run Audit</span>
<span x-show="auditLoading">Running...</span>
</button>
<span x-show="auditError" class="ml-3 text-sm text-red-600 dark:text-red-400" x-text="auditError"></span>
</div>
<!-- Summary cards -->
<template x-if="auditSummary">
<div class="grid grid-cols-4 gap-4 mb-6">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs p-4 text-center">
<div class="text-2xl font-bold text-gray-900 dark:text-white" x-text="auditSummary.total"></div>
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider mt-1">Total Routes</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs p-4 text-center border-t-2 border-green-500">
<div class="text-2xl font-bold text-green-600 dark:text-green-400" x-text="auditSummary.ok"></div>
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider mt-1">OK</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs p-4 text-center border-t-2 border-amber-500">
<div class="text-2xl font-bold text-amber-600 dark:text-amber-400" x-text="auditSummary.warnings"></div>
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider mt-1">Warnings</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs p-4 text-center border-t-2 border-red-500">
<div class="text-2xl font-bold text-red-600 dark:text-red-400" x-text="auditSummary.errors"></div>
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider mt-1">Errors</div>
</div>
</div>
</template>
<!-- Filter bar -->
<template x-if="auditRoutes.length > 0">
<div class="mb-4 flex items-center gap-4">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Frontend</label>
<select x-model="auditFilterFrontend"
class="px-3 py-1.5 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600 dark:text-white">
<option value="all">All</option>
<option value="admin">Admin</option>
<option value="store">Store</option>
<option value="merchant">Merchant</option>
<option value="storefront">Storefront</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Status</label>
<select x-model="auditFilterStatus"
class="px-3 py-1.5 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600 dark:text-white">
<option value="all">All</option>
<option value="ok">OK</option>
<option value="warning">Warnings</option>
<option value="error">Errors</option>
</select>
</div>
<div class="ml-auto text-sm text-gray-500 dark:text-gray-400">
Showing <span class="font-medium text-gray-900 dark:text-white" x-text="filteredAuditRoutes.length"></span>
of <span x-text="auditRoutes.length"></span> routes
</div>
</div>
</template>
<!-- Results table -->
<template x-if="auditRoutes.length > 0">
{% call table_wrapper() %}
{{ table_header(['Status', 'Frontend', 'Path', 'Endpoint', 'Auth', 'Issue']) }}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
{{ table_empty_state(6, title='No routes match your filters', icon='shield-check', show_condition='filteredAuditRoutes.length === 0') }}
<template x-for="route in filteredAuditRoutes" :key="route.path + route.methods.join()">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 text-sm">
<td class="px-4 py-2.5">
<span class="text-xs px-2 py-0.5 rounded-full font-medium"
:class="{
'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300': route.status === 'ok',
'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-300': route.status === 'warning',
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300': route.status === 'error'
}" x-text="route.status"></span>
</td>
<td class="px-4 py-2.5">
<span class="text-xs px-2 py-0.5 rounded-full font-medium"
:class="{
'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300': route.frontend === 'admin',
'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300': route.frontend === 'store',
'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300': route.frontend === 'merchant',
'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300': route.frontend === 'storefront',
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300': !route.frontend
}" x-text="route.frontend || 'other'"></span>
</td>
<td class="px-4 py-2.5 font-mono text-xs" x-text="route.path"></td>
<td class="px-4 py-2.5 text-xs text-gray-500 dark:text-gray-400" x-text="route.endpoint_name"></td>
<td class="px-4 py-2.5 text-xs">
<span x-text="route.auth_dependency || '—'" class="font-mono"></span>
<template x-if="route.auth_detail">
<span class="text-gray-400 dark:text-gray-500 ml-1" x-text="'(' + route.auth_detail + ')'"></span>
</template>
</td>
<td class="px-4 py-2.5 text-xs text-gray-500 dark:text-gray-400" x-text="route.issue || '—'"></td>
</tr>
</template>
</tbody>
{% endcall %}
</template>
</div>
</div>
</div> </div>
<script> <script>
function platformDebug() { function platformDebug() {
return { return {
// Inherit base layout functionality
...data(),
// Page identifier
currentPage: 'platform-debug',
// ── Sidebar / tool navigation ──
activeTool: 'platform-trace',
expandedCategories: ['Resolution', 'Security'],
toolGroups: [
{
category: 'Resolution',
items: [
{ id: 'platform-trace', label: 'Platform Trace', icon: 'search' },
],
},
{
category: 'Security',
items: [
{ id: 'permissions-audit', label: 'Permissions Audit', icon: 'shield-check' },
],
},
],
toggleCategory(cat) {
const idx = this.expandedCategories.indexOf(cat);
if (idx >= 0) this.expandedCategories.splice(idx, 1);
else this.expandedCategories.push(cat);
},
isCategoryExpanded(cat) {
return this.expandedCategories.includes(cat);
},
selectTool(toolId) {
this.activeTool = toolId;
},
// ── Platform Trace (existing) ──
running: false, running: false,
filterGroup: 'all', filterGroup: 'all',
customHost: 'localhost:8000', customHost: 'localhost:8000',
@@ -308,9 +512,6 @@ function platformDebug() {
}, },
// ── Prod custom domain ── // ── Prod custom domain ──
// Uses real StoreDomain records:
// wizatech.shop → store_id=1 (WIZATECH), platform_id=1 (oms)
// fashionhub.store → store_id=4 (FASHIONHUB), platform_id=3 (loyalty)
{ {
id: 'prod-c-1', group: 'prod-custom', groupLabel: 'Prod Custom', id: 'prod-c-1', group: 'prod-custom', groupLabel: 'Prod Custom',
label: 'wizatech.shop /store/login', label: 'wizatech.shop /store/login',
@@ -460,6 +661,35 @@ function platformDebug() {
return html; return html;
}, },
// ── Permissions Audit ──
auditRoutes: [],
auditSummary: null,
auditLoading: false,
auditError: '',
auditFilterFrontend: 'all',
auditFilterStatus: 'all',
get filteredAuditRoutes() {
return this.auditRoutes.filter(r => {
if (this.auditFilterFrontend !== 'all' && r.frontend !== this.auditFilterFrontend) return false;
if (this.auditFilterStatus !== 'all' && r.status !== this.auditFilterStatus) return false;
return true;
});
},
async runPermissionsAudit() {
this.auditLoading = true;
this.auditError = '';
try {
const resp = await apiClient.get('/admin/diagnostics/permissions-audit');
this.auditRoutes = resp.routes;
this.auditSummary = resp.summary;
} catch (e) {
this.auditError = e.message || 'Failed to run audit';
}
this.auditLoading = false;
},
}; };
} }
</script> </script>

View File

@@ -62,9 +62,9 @@
--- ---
## Dev URLs (localhost:9999) ## Dev URLs (localhost:8000)
The dev server uses path-based platform routing: `http://localhost:9999/platforms/loyalty/...` The dev server uses path-based platform routing: `http://localhost:8000/platforms/loyalty/...`
### 1. Platform Admin Pages ### 1. Platform Admin Pages
@@ -72,14 +72,14 @@ Login as: `admin@orion.lu` or `samir.boulahtit@gmail.com`
| Page | Dev URL | | Page | Dev URL |
|------|---------| |------|---------|
| Programs Dashboard | `http://localhost:9999/platforms/loyalty/admin/loyalty/programs` | | Programs Dashboard | `http://localhost:8000/platforms/loyalty/admin/loyalty/programs` |
| Analytics | `http://localhost:9999/platforms/loyalty/admin/loyalty/analytics` | | Analytics | `http://localhost:8000/platforms/loyalty/admin/loyalty/analytics` |
| WizaCorp Detail | `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/1` | | WizaCorp Detail | `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/1` |
| WizaCorp Settings | `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/1/settings` | | WizaCorp Settings | `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/1/settings` |
| Fashion Group Detail | `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/2` | | Fashion Group Detail | `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/2` |
| Fashion Group Settings | `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/2/settings` | | Fashion Group Settings | `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/2/settings` |
| BookWorld Detail | `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/3` | | BookWorld Detail | `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/3` |
| BookWorld Settings | `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/3/settings` | | BookWorld Settings | `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/3/settings` |
### 2. Merchant Owner / Store Pages ### 2. Merchant Owner / Store Pages
@@ -89,87 +89,87 @@ Login as the store owner, then navigate to any of their stores.
| Page | Dev URL | | Page | Dev URL |
|------|---------| |------|---------|
| Terminal | `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/terminal` | | Terminal | `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/terminal` |
| Cards | `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/cards` | | Cards | `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/cards` |
| Settings | `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/settings` | | Settings | `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/settings` |
| Stats | `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/stats` | | Stats | `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/stats` |
| Enroll Customer | `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/enroll` | | Enroll Customer | `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/enroll` |
**Fashion Group (jane.owner@fashiongroup.com):** **Fashion Group (jane.owner@fashiongroup.com):**
| Page | Dev URL | | Page | Dev URL |
|------|---------| |------|---------|
| Terminal | `http://localhost:9999/platforms/loyalty/store/FASHIONHUB/loyalty/terminal` | | Terminal | `http://localhost:8000/platforms/loyalty/store/FASHIONHUB/loyalty/terminal` |
| Cards | `http://localhost:9999/platforms/loyalty/store/FASHIONHUB/loyalty/cards` | | Cards | `http://localhost:8000/platforms/loyalty/store/FASHIONHUB/loyalty/cards` |
| Settings | `http://localhost:9999/platforms/loyalty/store/FASHIONHUB/loyalty/settings` | | Settings | `http://localhost:8000/platforms/loyalty/store/FASHIONHUB/loyalty/settings` |
| Stats | `http://localhost:9999/platforms/loyalty/store/FASHIONHUB/loyalty/stats` | | Stats | `http://localhost:8000/platforms/loyalty/store/FASHIONHUB/loyalty/stats` |
| Enroll Customer | `http://localhost:9999/platforms/loyalty/store/FASHIONHUB/loyalty/enroll` | | Enroll Customer | `http://localhost:8000/platforms/loyalty/store/FASHIONHUB/loyalty/enroll` |
**BookWorld (bob.owner@bookworld.com):** **BookWorld (bob.owner@bookworld.com):**
| Page | Dev URL | | Page | Dev URL |
|------|---------| |------|---------|
| Terminal | `http://localhost:9999/platforms/loyalty/store/BOOKSTORE/loyalty/terminal` | | Terminal | `http://localhost:8000/platforms/loyalty/store/BOOKSTORE/loyalty/terminal` |
| Cards | `http://localhost:9999/platforms/loyalty/store/BOOKSTORE/loyalty/cards` | | Cards | `http://localhost:8000/platforms/loyalty/store/BOOKSTORE/loyalty/cards` |
| Settings | `http://localhost:9999/platforms/loyalty/store/BOOKSTORE/loyalty/settings` | | Settings | `http://localhost:8000/platforms/loyalty/store/BOOKSTORE/loyalty/settings` |
| Stats | `http://localhost:9999/platforms/loyalty/store/BOOKSTORE/loyalty/stats` | | Stats | `http://localhost:8000/platforms/loyalty/store/BOOKSTORE/loyalty/stats` |
| Enroll Customer | `http://localhost:9999/platforms/loyalty/store/BOOKSTORE/loyalty/enroll` | | Enroll Customer | `http://localhost:8000/platforms/loyalty/store/BOOKSTORE/loyalty/enroll` |
### 3. Customer Storefront Pages ### 3. Customer Storefront Pages
Login as a customer (e.g., `customer1@orion.example.com`). Login as a customer (e.g., `customer1@orion.example.com`).
!!! note "Store domain required" !!! note "Store code required in dev"
Storefront pages require a store domain context. Only ORION (`orion.shop`) Storefront pages in dev require the store code in the URL path:
and FASHIONHUB (`fashionhub.store`) have domains configured. In dev, storefront `/platforms/loyalty/storefront/{STORE_CODE}/...`. In production, the store
routes may need to be accessed through the store's domain or platform path. is resolved from the domain (custom domain, merchant domain, or subdomain).
| Page | Dev URL | | Page | Dev URL (FASHIONHUB example) |
|------|---------| |------|---------|
| Loyalty Dashboard | `http://localhost:9999/platforms/loyalty/account/loyalty` | | Loyalty Dashboard | `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/account/loyalty` |
| Transaction History | `http://localhost:9999/platforms/loyalty/account/loyalty/history` | | Transaction History | `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/account/loyalty/history` |
### 4. Public Pages (No Auth) ### 4. Public Pages (No Auth)
| Page | Dev URL | | Page | Dev URL (FASHIONHUB example) |
|------|---------| |------|---------|
| Self-Enrollment | `http://localhost:9999/platforms/loyalty/loyalty/join` | | Self-Enrollment | `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/loyalty/join` |
| Enrollment Success | `http://localhost:9999/platforms/loyalty/loyalty/join/success` | | Enrollment Success | `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/loyalty/join/success` |
### 5. API Endpoints ### 5. API Endpoints
**Admin API** (prefix: `/platforms/loyalty/api/admin/loyalty/`): **Admin API** (prefix: `/platforms/loyalty/api/v1/admin/loyalty/`):
| Method | Dev URL | | Method | Dev URL |
|--------|---------| |--------|---------|
| GET | `http://localhost:9999/platforms/loyalty/api/admin/loyalty/programs` | | GET | `http://localhost:8000/platforms/loyalty/api/v1/admin/loyalty/programs` |
| GET | `http://localhost:9999/platforms/loyalty/api/admin/loyalty/stats` | | GET | `http://localhost:8000/platforms/loyalty/api/v1/admin/loyalty/stats` |
**Store API** (prefix: `/platforms/loyalty/api/store/loyalty/`): **Store API** (prefix: `/platforms/loyalty/api/v1/store/loyalty/`):
| Method | Endpoint | Dev URL | | Method | Endpoint | Dev URL |
|--------|----------|---------| |--------|----------|---------|
| GET | program | `http://localhost:9999/platforms/loyalty/api/store/loyalty/program` | | GET | program | `http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/program` |
| POST | program | `http://localhost:9999/platforms/loyalty/api/store/loyalty/program` | | POST | program | `http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/program` |
| POST | stamp | `http://localhost:9999/platforms/loyalty/api/store/loyalty/stamp` | | POST | stamp | `http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/stamp` |
| POST | points | `http://localhost:9999/platforms/loyalty/api/store/loyalty/points` | | POST | points | `http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/points` |
| POST | enroll | `http://localhost:9999/platforms/loyalty/api/store/loyalty/cards/enroll` | | POST | enroll | `http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/cards/enroll` |
| POST | lookup | `http://localhost:9999/platforms/loyalty/api/store/loyalty/cards/lookup` | | POST | lookup | `http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/cards/lookup` |
**Storefront API** (prefix: `/platforms/loyalty/api/storefront/`): **Storefront API** (prefix: `/platforms/loyalty/api/v1/storefront/`):
| Method | Endpoint | Dev URL | | Method | Endpoint | Dev URL |
|--------|----------|---------| |--------|----------|---------|
| GET | program | `http://localhost:9999/platforms/loyalty/api/storefront/loyalty/program` | | GET | program | `http://localhost:8000/platforms/loyalty/api/v1/storefront/loyalty/program` |
| POST | enroll | `http://localhost:9999/platforms/loyalty/api/storefront/loyalty/enroll` | | POST | enroll | `http://localhost:8000/platforms/loyalty/api/v1/storefront/loyalty/enroll` |
| GET | card | `http://localhost:9999/platforms/loyalty/api/storefront/loyalty/card` | | GET | card | `http://localhost:8000/platforms/loyalty/api/v1/storefront/loyalty/card` |
| GET | transactions | `http://localhost:9999/platforms/loyalty/api/storefront/loyalty/transactions` | | GET | transactions | `http://localhost:8000/platforms/loyalty/api/v1/storefront/loyalty/transactions` |
**Public API** (prefix: `/platforms/loyalty/api/loyalty/`): **Public API** (prefix: `/platforms/loyalty/api/v1/loyalty/`):
| Method | Endpoint | Dev URL | | Method | Endpoint | Dev URL |
|--------|----------|---------| |--------|----------|---------|
| GET | program | `http://localhost:9999/platforms/loyalty/api/loyalty/programs/ORION` | | GET | program | `http://localhost:8000/platforms/loyalty/api/v1/loyalty/programs/ORION` |
--- ---
@@ -213,10 +213,10 @@ The store has a verified entry in the `store_domains` table. **All** store URLs
| Method | Production URL | | Method | Production URL |
|--------|----------------| |--------|----------------|
| GET card | `https://orion.shop/api/storefront/loyalty/card` | | GET card | `https://orion.shop/api/v1/storefront/loyalty/card` |
| GET transactions | `https://orion.shop/api/storefront/loyalty/transactions` | | GET transactions | `https://orion.shop/api/v1/storefront/loyalty/transactions` |
| POST enroll | `https://orion.shop/api/storefront/loyalty/enroll` | | POST enroll | `https://orion.shop/api/v1/storefront/loyalty/enroll` |
| GET program | `https://orion.shop/api/storefront/loyalty/program` | | GET program | `https://orion.shop/api/v1/storefront/loyalty/program` |
**Store backend (staff/owner):** **Store backend (staff/owner):**
@@ -234,12 +234,12 @@ The store has a verified entry in the `store_domains` table. **All** store URLs
| Method | Production URL | | Method | Production URL |
|--------|----------------| |--------|----------------|
| GET program | `https://orion.shop/api/store/loyalty/program` | | GET program | `https://orion.shop/api/v1/store/loyalty/program` |
| POST program | `https://orion.shop/api/store/loyalty/program` | | POST program | `https://orion.shop/api/v1/store/loyalty/program` |
| POST stamp | `https://orion.shop/api/store/loyalty/stamp` | | POST stamp | `https://orion.shop/api/v1/store/loyalty/stamp` |
| POST points | `https://orion.shop/api/store/loyalty/points` | | POST points | `https://orion.shop/api/v1/store/loyalty/points` |
| POST enroll | `https://orion.shop/api/store/loyalty/cards/enroll` | | POST enroll | `https://orion.shop/api/v1/store/loyalty/cards/enroll` |
| POST lookup | `https://orion.shop/api/store/loyalty/cards/lookup` | | POST lookup | `https://orion.shop/api/v1/store/loyalty/cards/lookup` |
### Case 2: Store with merchant domain (e.g., `myloyaltyprogram.lu`) ### Case 2: Store with merchant domain (e.g., `myloyaltyprogram.lu`)
@@ -261,10 +261,10 @@ store when the URL includes `/store/{store_code}/...`.
| Method | Production URL | | Method | Production URL |
|--------|----------------| |--------|----------------|
| GET card | `https://myloyaltyprogram.lu/api/storefront/loyalty/card` | | GET card | `https://myloyaltyprogram.lu/api/v1/storefront/loyalty/card` |
| GET transactions | `https://myloyaltyprogram.lu/api/storefront/loyalty/transactions` | | GET transactions | `https://myloyaltyprogram.lu/api/v1/storefront/loyalty/transactions` |
| POST enroll | `https://myloyaltyprogram.lu/api/storefront/loyalty/enroll` | | POST enroll | `https://myloyaltyprogram.lu/api/v1/storefront/loyalty/enroll` |
| GET program | `https://myloyaltyprogram.lu/api/storefront/loyalty/program` | | GET program | `https://myloyaltyprogram.lu/api/v1/storefront/loyalty/program` |
**Store backend (staff/owner):** **Store backend (staff/owner):**
@@ -280,11 +280,11 @@ store when the URL includes `/store/{store_code}/...`.
| Method | Production URL | | Method | Production URL |
|--------|----------------| |--------|----------------|
| GET program | `https://myloyaltyprogram.lu/api/store/loyalty/program` | | GET program | `https://myloyaltyprogram.lu/api/v1/store/loyalty/program` |
| POST stamp | `https://myloyaltyprogram.lu/api/store/loyalty/stamp` | | POST stamp | `https://myloyaltyprogram.lu/api/v1/store/loyalty/stamp` |
| POST points | `https://myloyaltyprogram.lu/api/store/loyalty/points` | | POST points | `https://myloyaltyprogram.lu/api/v1/store/loyalty/points` |
| POST enroll | `https://myloyaltyprogram.lu/api/store/loyalty/cards/enroll` | | POST enroll | `https://myloyaltyprogram.lu/api/v1/store/loyalty/cards/enroll` |
| POST lookup | `https://myloyaltyprogram.lu/api/store/loyalty/cards/lookup` | | POST lookup | `https://myloyaltyprogram.lu/api/v1/store/loyalty/cards/lookup` |
!!! note "Merchant domain resolves to first active store" !!! note "Merchant domain resolves to first active store"
When a customer visits `myloyaltyprogram.lu` without a `/store/{code}/...` path, When a customer visits `myloyaltyprogram.lu` without a `/store/{code}/...` path,
@@ -310,10 +310,10 @@ The store has no entry in `store_domains` and the merchant has no registered dom
| Method | Production URL | | Method | Production URL |
|--------|----------------| |--------|----------------|
| GET card | `https://bookstore.rewardflow.lu/api/storefront/loyalty/card` | | GET card | `https://bookstore.rewardflow.lu/api/v1/storefront/loyalty/card` |
| GET transactions | `https://bookstore.rewardflow.lu/api/storefront/loyalty/transactions` | | GET transactions | `https://bookstore.rewardflow.lu/api/v1/storefront/loyalty/transactions` |
| POST enroll | `https://bookstore.rewardflow.lu/api/storefront/loyalty/enroll` | | POST enroll | `https://bookstore.rewardflow.lu/api/v1/storefront/loyalty/enroll` |
| GET program | `https://bookstore.rewardflow.lu/api/storefront/loyalty/program` | | GET program | `https://bookstore.rewardflow.lu/api/v1/storefront/loyalty/program` |
**Store backend (staff/owner):** **Store backend (staff/owner):**
@@ -329,11 +329,11 @@ The store has no entry in `store_domains` and the merchant has no registered dom
| Method | Production URL | | Method | Production URL |
|--------|----------------| |--------|----------------|
| GET program | `https://bookstore.rewardflow.lu/api/store/loyalty/program` | | GET program | `https://bookstore.rewardflow.lu/api/v1/store/loyalty/program` |
| POST stamp | `https://bookstore.rewardflow.lu/api/store/loyalty/stamp` | | POST stamp | `https://bookstore.rewardflow.lu/api/v1/store/loyalty/stamp` |
| POST points | `https://bookstore.rewardflow.lu/api/store/loyalty/points` | | POST points | `https://bookstore.rewardflow.lu/api/v1/store/loyalty/points` |
| POST enroll | `https://bookstore.rewardflow.lu/api/store/loyalty/cards/enroll` | | POST enroll | `https://bookstore.rewardflow.lu/api/v1/store/loyalty/cards/enroll` |
| POST lookup | `https://bookstore.rewardflow.lu/api/store/loyalty/cards/lookup` | | POST lookup | `https://bookstore.rewardflow.lu/api/v1/store/loyalty/cards/lookup` |
### Platform Admin & Public API (always on platform domain) ### Platform Admin & Public API (always on platform domain)
@@ -343,10 +343,10 @@ The store has no entry in `store_domains` and the merchant has no registered dom
| Admin Analytics | `https://rewardflow.lu/admin/loyalty/analytics` | | Admin Analytics | `https://rewardflow.lu/admin/loyalty/analytics` |
| Admin Merchant Detail | `https://rewardflow.lu/admin/loyalty/merchants/{id}` | | Admin Merchant Detail | `https://rewardflow.lu/admin/loyalty/merchants/{id}` |
| Admin Merchant Settings | `https://rewardflow.lu/admin/loyalty/merchants/{id}/settings` | | Admin Merchant Settings | `https://rewardflow.lu/admin/loyalty/merchants/{id}/settings` |
| Admin API - Programs | `GET https://rewardflow.lu/api/admin/loyalty/programs` | | Admin API - Programs | `GET https://rewardflow.lu/api/v1/admin/loyalty/programs` |
| Admin API - Stats | `GET https://rewardflow.lu/api/admin/loyalty/stats` | | Admin API - Stats | `GET https://rewardflow.lu/api/v1/admin/loyalty/stats` |
| Public API - Program | `GET https://rewardflow.lu/api/loyalty/programs/ORION` | | Public API - Program | `GET https://rewardflow.lu/api/v1/loyalty/programs/ORION` |
| Apple Wallet Pass | `GET https://rewardflow.lu/api/loyalty/passes/apple/{serial}.pkpass` | | Apple Wallet Pass | `GET https://rewardflow.lu/api/v1/loyalty/passes/apple/{serial}.pkpass` |
### Domain configuration per store (current DB state) ### Domain configuration per store (current DB state)
@@ -418,19 +418,19 @@ flowchart TD
**Step 1: Subscribe to the platform** **Step 1: Subscribe to the platform**
1. Login as `john.owner@wizacorp.com` and navigate to billing: 1. Login as `john.owner@wizacorp.com` and navigate to billing:
- Dev: `http://localhost:9999/platforms/loyalty/store/ORION/billing` - Dev: `http://localhost:8000/platforms/loyalty/store/ORION/billing`
- Prod (custom domain): `https://orion.shop/store/ORION/billing` - Prod (custom domain): `https://orion.shop/store/ORION/billing`
- Prod (subdomain): `https://orion.rewardflow.lu/store/ORION/billing` - Prod (subdomain): `https://orion.rewardflow.lu/store/ORION/billing`
2. View available subscription tiers: 2. View available subscription tiers:
- API Dev: `GET http://localhost:9999/platforms/loyalty/api/v1/store/billing/tiers` - API Dev: `GET http://localhost:8000/platforms/loyalty/api/v1/store/billing/tiers`
- API Prod: `GET https://{store_domain}/api/v1/store/billing/tiers` - API Prod: `GET https://{store_domain}/api/v1/store/billing/tiers`
3. Select a tier and initiate Stripe checkout: 3. Select a tier and initiate Stripe checkout:
- API Dev: `POST http://localhost:9999/platforms/loyalty/api/v1/store/billing/checkout` - API Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/billing/checkout`
- API Prod: `POST https://{store_domain}/api/v1/store/billing/checkout` - API Prod: `POST https://{store_domain}/api/v1/store/billing/checkout`
4. Complete payment on Stripe checkout page 4. Complete payment on Stripe checkout page
5. Webhook `checkout.session.completed` activates the subscription 5. Webhook `checkout.session.completed` activates the subscription
6. Verify subscription is active: 6. Verify subscription is active:
- API Dev: `GET http://localhost:9999/platforms/loyalty/api/v1/store/billing/subscription` - API Dev: `GET http://localhost:8000/platforms/loyalty/api/v1/store/billing/subscription`
- API Prod: `GET https://{store_domain}/api/v1/store/billing/subscription` - API Prod: `GET https://{store_domain}/api/v1/store/billing/subscription`
**Step 2: Register merchant domain (admin action)** **Step 2: Register merchant domain (admin action)**
@@ -440,19 +440,19 @@ flowchart TD
registers the domain on behalf of the merchant via the admin API. registers the domain on behalf of the merchant via the admin API.
1. Platform admin registers a merchant domain: 1. Platform admin registers a merchant domain:
- API Dev: `POST http://localhost:9999/platforms/loyalty/api/v1/admin/merchants/{merchant_id}/domains` - API Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/admin/merchants/{merchant_id}/domains`
- API Prod: `POST https://rewardflow.lu/api/v1/admin/merchants/{merchant_id}/domains` - API Prod: `POST https://rewardflow.lu/api/v1/admin/merchants/{merchant_id}/domains`
- Body: `{"domain": "myloyaltyprogram.lu", "is_primary": true}` - Body: `{"domain": "myloyaltyprogram.lu", "is_primary": true}`
2. The API returns a `verification_token` for DNS verification 2. The API returns a `verification_token` for DNS verification
3. Get DNS verification instructions: 3. Get DNS verification instructions:
- API Dev: `GET http://localhost:9999/platforms/loyalty/api/v1/admin/merchants/domains/merchant/{domain_id}/verification-instructions` - API Dev: `GET http://localhost:8000/platforms/loyalty/api/v1/admin/merchants/domains/merchant/{domain_id}/verification-instructions`
- API Prod: `GET https://rewardflow.lu/api/v1/admin/merchants/domains/merchant/{domain_id}/verification-instructions` - API Prod: `GET https://rewardflow.lu/api/v1/admin/merchants/domains/merchant/{domain_id}/verification-instructions`
4. Merchant adds a DNS TXT record: `_orion-verify.myloyaltyprogram.lu TXT {verification_token}` 4. Merchant adds a DNS TXT record: `_orion-verify.myloyaltyprogram.lu TXT {verification_token}`
5. Verify the domain: 5. Verify the domain:
- API Dev: `POST http://localhost:9999/platforms/loyalty/api/v1/admin/merchants/domains/merchant/{domain_id}/verify` - API Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/admin/merchants/domains/merchant/{domain_id}/verify`
- API Prod: `POST https://rewardflow.lu/api/v1/admin/merchants/domains/merchant/{domain_id}/verify` - API Prod: `POST https://rewardflow.lu/api/v1/admin/merchants/domains/merchant/{domain_id}/verify`
6. Activate the domain: 6. Activate the domain:
- API Dev: `PUT http://localhost:9999/platforms/loyalty/api/v1/admin/merchants/domains/merchant/{domain_id}` - API Dev: `PUT http://localhost:8000/platforms/loyalty/api/v1/admin/merchants/domains/merchant/{domain_id}`
- API Prod: `PUT https://rewardflow.lu/api/v1/admin/merchants/domains/merchant/{domain_id}` - API Prod: `PUT https://rewardflow.lu/api/v1/admin/merchants/domains/merchant/{domain_id}`
- Body: `{"is_active": true}` - Body: `{"is_active": true}`
7. All merchant stores now inherit `myloyaltyprogram.lu` as their effective domain 7. All merchant stores now inherit `myloyaltyprogram.lu` as their effective domain
@@ -462,7 +462,7 @@ flowchart TD
If a store needs its own domain (e.g., ORION is a major brand and wants `mysuperloyaltyprogram.lu`): If a store needs its own domain (e.g., ORION is a major brand and wants `mysuperloyaltyprogram.lu`):
1. Platform admin registers a store domain: 1. Platform admin registers a store domain:
- API Dev: `POST http://localhost:9999/platforms/loyalty/api/v1/admin/stores/{store_id}/domains` - API Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/admin/stores/{store_id}/domains`
- API Prod: `POST https://rewardflow.lu/api/v1/admin/stores/{store_id}/domains` - API Prod: `POST https://rewardflow.lu/api/v1/admin/stores/{store_id}/domains`
- Body: `{"domain": "mysuperloyaltyprogram.lu", "is_primary": true}` - Body: `{"domain": "mysuperloyaltyprogram.lu", "is_primary": true}`
2. Follow the same DNS verification and activation flow as merchant domains 2. Follow the same DNS verification and activation flow as merchant domains
@@ -507,25 +507,25 @@ flowchart TD
**Steps:** **Steps:**
1. Login as `john.owner@wizacorp.com` at: 1. Login as `john.owner@wizacorp.com` at:
- Dev: `http://localhost:9999/platforms/loyalty/store/ORION/login` - Dev: `http://localhost:8000/platforms/loyalty/store/ORION/login`
- Prod (custom domain): `https://orion.shop/store/ORION/login` - Prod (custom domain): `https://orion.shop/store/ORION/login`
- Prod (subdomain): `https://orion.rewardflow.lu/store/ORION/login` - Prod (subdomain): `https://orion.rewardflow.lu/store/ORION/login`
2. Navigate to loyalty settings: 2. Navigate to loyalty settings:
- Dev: `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/settings` - Dev: `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/settings`
- Prod (custom domain): `https://orion.shop/store/ORION/loyalty/settings` - Prod (custom domain): `https://orion.shop/store/ORION/loyalty/settings`
- Prod (subdomain): `https://orion.rewardflow.lu/store/ORION/loyalty/settings` - Prod (subdomain): `https://orion.rewardflow.lu/store/ORION/loyalty/settings`
3. Create a new loyalty program: 3. Create a new loyalty program:
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/program` - Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/program`
- Prod: `POST https://{store_domain}/api/store/loyalty/program` - Prod: `POST https://{store_domain}/api/v1/store/loyalty/program`
4. Choose loyalty type (stamps, points, or hybrid) 4. Choose loyalty type (stamps, points, or hybrid)
5. Configure program parameters (stamp target, points-per-euro, rewards) 5. Configure program parameters (stamp target, points-per-euro, rewards)
6. Set branding (card color, logo, hero image) 6. Set branding (card color, logo, hero image)
7. Configure anti-fraud (cooldown, daily limits, PIN requirements) 7. Configure anti-fraud (cooldown, daily limits, PIN requirements)
8. Create staff PINs: 8. Create staff PINs:
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/pins` - Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/pins`
- Prod: `POST https://{store_domain}/api/store/loyalty/pins` - Prod: `POST https://{store_domain}/api/v1/store/loyalty/pins`
9. Verify program is live - check from another store (same merchant): 9. Verify program is live - check from another store (same merchant):
- Dev: `http://localhost:9999/platforms/loyalty/store/WIZAGADGETS/loyalty/settings` - Dev: `http://localhost:8000/platforms/loyalty/store/WIZAGADGETS/loyalty/settings`
- Prod (subdomain): `https://wizagadgets.rewardflow.lu/store/WIZAGADGETS/loyalty/settings` - Prod (subdomain): `https://wizagadgets.rewardflow.lu/store/WIZAGADGETS/loyalty/settings`
**Expected blockers in current state:** **Expected blockers in current state:**
@@ -563,23 +563,23 @@ flowchart TD
**Steps:** **Steps:**
1. Login as `alice.manager@wizacorp.com` and open the terminal: 1. Login as `alice.manager@wizacorp.com` and open the terminal:
- Dev: `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/terminal` - Dev: `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/terminal`
- Prod: `https://{store_domain}/store/ORION/loyalty/terminal` - Prod: `https://{store_domain}/store/ORION/loyalty/terminal`
2. Scan customer QR code or enter card number: 2. Scan customer QR code or enter card number:
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/cards/lookup` - Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/cards/lookup`
- Prod: `POST https://{store_domain}/api/store/loyalty/cards/lookup` - Prod: `POST https://{store_domain}/api/v1/store/loyalty/cards/lookup`
3. Enter staff PIN for verification 3. Enter staff PIN for verification
4. Add stamp: 4. Add stamp:
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/stamp` - Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/stamp`
- Prod: `POST https://{store_domain}/api/store/loyalty/stamp` - Prod: `POST https://{store_domain}/api/v1/store/loyalty/stamp`
5. If target reached, redeem reward: 5. If target reached, redeem reward:
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/stamp/redeem` - Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/stamp/redeem`
- Prod: `POST https://{store_domain}/api/store/loyalty/stamp/redeem` - Prod: `POST https://{store_domain}/api/v1/store/loyalty/stamp/redeem`
6. View updated card: 6. View updated card:
- Dev: `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/cards/{card_id}` - Dev: `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/cards/{card_id}`
- Prod: `https://{store_domain}/store/ORION/loyalty/cards/{card_id}` - Prod: `https://{store_domain}/store/ORION/loyalty/cards/{card_id}`
7. Browse all cards: 7. Browse all cards:
- Dev: `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/cards` - Dev: `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/cards`
- Prod: `https://{store_domain}/store/ORION/loyalty/cards` - Prod: `https://{store_domain}/store/ORION/loyalty/cards`
**Anti-fraud scenarios to test:** **Anti-fraud scenarios to test:**
@@ -611,20 +611,20 @@ flowchart TD
**Steps:** **Steps:**
1. Open the terminal: 1. Open the terminal:
- Dev: `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/terminal` - Dev: `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/terminal`
- Prod: `https://{store_domain}/store/ORION/loyalty/terminal` - Prod: `https://{store_domain}/store/ORION/loyalty/terminal`
2. Lookup card: 2. Lookup card:
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/cards/lookup` - Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/cards/lookup`
- Prod: `POST https://{store_domain}/api/store/loyalty/cards/lookup` - Prod: `POST https://{store_domain}/api/v1/store/loyalty/cards/lookup`
3. Enter purchase amount (e.g., 25.00 EUR) 3. Enter purchase amount (e.g., 25.00 EUR)
4. Earn points (auto-calculated at 10 pts/EUR = 250 points): 4. Earn points (auto-calculated at 10 pts/EUR = 250 points):
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/points` - Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/points`
- Prod: `POST https://{store_domain}/api/store/loyalty/points` - Prod: `POST https://{store_domain}/api/v1/store/loyalty/points`
5. If enough balance, redeem points for reward: 5. If enough balance, redeem points for reward:
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/points/redeem` - Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/points/redeem`
- Prod: `POST https://{store_domain}/api/store/loyalty/points/redeem` - Prod: `POST https://{store_domain}/api/v1/store/loyalty/points/redeem`
6. Check store-level stats: 6. Check store-level stats:
- Dev: `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/stats` - Dev: `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/stats`
- Prod: `https://{store_domain}/store/ORION/loyalty/stats` - Prod: `https://{store_domain}/store/ORION/loyalty/stats`
--- ---
@@ -647,21 +647,21 @@ flowchart TD
**Steps:** **Steps:**
1. Visit the public enrollment page: 1. Visit the public enrollment page:
- Dev: `http://localhost:9999/platforms/loyalty/loyalty/join` - Dev: `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/loyalty/join`
- Prod (custom domain): `https://orion.shop/loyalty/join` - Prod (custom domain): `https://fashionhub.store/loyalty/join`
- Prod (subdomain): `https://bookstore.rewardflow.lu/loyalty/join` - Prod (subdomain): `https://bookstore.rewardflow.lu/loyalty/join`
2. Fill in enrollment form (email, name) 2. Fill in enrollment form (email, name)
3. Submit enrollment: 3. Submit enrollment:
- Dev: `POST http://localhost:9999/platforms/loyalty/api/storefront/loyalty/enroll` - Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/storefront/loyalty/enroll`
- Prod (custom domain): `POST https://orion.shop/api/storefront/loyalty/enroll` - Prod (custom domain): `POST https://fashionhub.store/api/v1/storefront/loyalty/enroll`
- Prod (subdomain): `POST https://bookstore.rewardflow.lu/api/storefront/loyalty/enroll` - Prod (subdomain): `POST https://bookstore.rewardflow.lu/api/v1/storefront/loyalty/enroll`
4. Redirected to success page: 4. Redirected to success page:
- Dev: `http://localhost:9999/platforms/loyalty/loyalty/join/success?card=XXXX-XXXX-XXXX` - Dev: `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/loyalty/join/success?card=XXXX-XXXX-XXXX`
- Prod (custom domain): `https://orion.shop/loyalty/join/success?card=XXXX-XXXX-XXXX` - Prod (custom domain): `https://fashionhub.store/loyalty/join/success?card=XXXX-XXXX-XXXX`
- Prod (subdomain): `https://bookstore.rewardflow.lu/loyalty/join/success?card=XXXX-XXXX-XXXX` - Prod (subdomain): `https://bookstore.rewardflow.lu/loyalty/join/success?card=XXXX-XXXX-XXXX`
5. Optionally download Apple Wallet pass: 5. Optionally download Apple Wallet pass:
- Dev: `GET http://localhost:9999/platforms/loyalty/api/loyalty/passes/apple/{serial_number}.pkpass` - Dev: `GET http://localhost:8000/platforms/loyalty/api/v1/loyalty/passes/apple/{serial_number}.pkpass`
- Prod: `GET https://rewardflow.lu/api/loyalty/passes/apple/{serial_number}.pkpass` - Prod: `GET https://rewardflow.lu/api/v1/loyalty/passes/apple/{serial_number}.pkpass`
--- ---
@@ -674,17 +674,17 @@ flowchart TD
1. Login as customer at the storefront 1. Login as customer at the storefront
2. View loyalty dashboard (card balance, available rewards): 2. View loyalty dashboard (card balance, available rewards):
- Dev: `http://localhost:9999/platforms/loyalty/account/loyalty` - Dev: `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/account/loyalty`
- Prod (custom domain): `https://orion.shop/account/loyalty` - Prod (custom domain): `https://fashionhub.store/account/loyalty`
- Prod (subdomain): `https://bookstore.rewardflow.lu/account/loyalty` - Prod (subdomain): `https://bookstore.rewardflow.lu/account/loyalty`
- API Dev: `GET http://localhost:9999/platforms/loyalty/api/storefront/loyalty/card` - API Dev: `GET http://localhost:8000/platforms/loyalty/api/v1/storefront/loyalty/card`
- API Prod: `GET https://orion.shop/api/storefront/loyalty/card` - API Prod: `GET https://fashionhub.store/api/v1/storefront/loyalty/card`
3. View full transaction history: 3. View full transaction history:
- Dev: `http://localhost:9999/platforms/loyalty/account/loyalty/history` - Dev: `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/account/loyalty/history`
- Prod (custom domain): `https://orion.shop/account/loyalty/history` - Prod (custom domain): `https://fashionhub.store/account/loyalty/history`
- Prod (subdomain): `https://bookstore.rewardflow.lu/account/loyalty/history` - Prod (subdomain): `https://bookstore.rewardflow.lu/account/loyalty/history`
- API Dev: `GET http://localhost:9999/platforms/loyalty/api/storefront/loyalty/transactions` - API Dev: `GET http://localhost:8000/platforms/loyalty/api/v1/storefront/loyalty/transactions`
- API Prod: `GET https://orion.shop/api/storefront/loyalty/transactions` - API Prod: `GET https://fashionhub.store/api/v1/storefront/loyalty/transactions`
--- ---
@@ -697,22 +697,22 @@ flowchart TD
1. Login as admin 1. Login as admin
2. View all programs: 2. View all programs:
- Dev: `http://localhost:9999/platforms/loyalty/admin/loyalty/programs` - Dev: `http://localhost:8000/platforms/loyalty/admin/loyalty/programs`
- Prod: `https://rewardflow.lu/admin/loyalty/programs` - Prod: `https://rewardflow.lu/admin/loyalty/programs`
3. View platform-wide analytics: 3. View platform-wide analytics:
- Dev: `http://localhost:9999/platforms/loyalty/admin/loyalty/analytics` - Dev: `http://localhost:8000/platforms/loyalty/admin/loyalty/analytics`
- Prod: `https://rewardflow.lu/admin/loyalty/analytics` - Prod: `https://rewardflow.lu/admin/loyalty/analytics`
4. Drill into WizaCorp's program: 4. Drill into WizaCorp's program:
- Dev: `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/1` - Dev: `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/1`
- Prod: `https://rewardflow.lu/admin/loyalty/merchants/1` - Prod: `https://rewardflow.lu/admin/loyalty/merchants/1`
5. Manage WizaCorp's merchant-level settings: 5. Manage WizaCorp's merchant-level settings:
- Dev: `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/1/settings` - Dev: `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/1/settings`
- Prod: `https://rewardflow.lu/admin/loyalty/merchants/1/settings` - Prod: `https://rewardflow.lu/admin/loyalty/merchants/1/settings`
- API Dev: `PATCH http://localhost:9999/platforms/loyalty/api/admin/loyalty/merchants/1/settings` - API Dev: `PATCH http://localhost:8000/platforms/loyalty/api/v1/admin/loyalty/merchants/1/settings`
- API Prod: `PATCH https://rewardflow.lu/api/admin/loyalty/merchants/1/settings` - API Prod: `PATCH https://rewardflow.lu/api/v1/admin/loyalty/merchants/1/settings`
6. Adjust settings: PIN policy, self-enrollment toggle, void permissions 6. Adjust settings: PIN policy, self-enrollment toggle, void permissions
7. Check other merchants: 7. Check other merchants:
- Dev: `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/2` - Dev: `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/2`
- Prod: `https://rewardflow.lu/admin/loyalty/merchants/2` - Prod: `https://rewardflow.lu/admin/loyalty/merchants/2`
--- ---
@@ -725,21 +725,21 @@ flowchart TD
**Steps:** **Steps:**
1. Open terminal and lookup card: 1. Open terminal and lookup card:
- Dev: `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/terminal` - Dev: `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/terminal`
- Prod: `https://{store_domain}/store/ORION/loyalty/terminal` - Prod: `https://{store_domain}/store/ORION/loyalty/terminal`
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/cards/lookup` - Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/cards/lookup`
- Prod: `POST https://{store_domain}/api/store/loyalty/cards/lookup` - Prod: `POST https://{store_domain}/api/v1/store/loyalty/cards/lookup`
2. View the card's transaction history to find the transaction to void: 2. View the card's transaction history to find the transaction to void:
- Dev: `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/cards/{card_id}` - Dev: `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/cards/{card_id}`
- Prod: `https://{store_domain}/store/ORION/loyalty/cards/{card_id}` - Prod: `https://{store_domain}/store/ORION/loyalty/cards/{card_id}`
- API Dev: `GET http://localhost:9999/platforms/loyalty/api/store/loyalty/cards/{card_id}/transactions` - API Dev: `GET http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/cards/{card_id}/transactions`
- API Prod: `GET https://{store_domain}/api/store/loyalty/cards/{card_id}/transactions` - API Prod: `GET https://{store_domain}/api/v1/store/loyalty/cards/{card_id}/transactions`
3. Void a stamp transaction: 3. Void a stamp transaction:
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/stamp/void` - Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/stamp/void`
- Prod: `POST https://{store_domain}/api/store/loyalty/stamp/void` - Prod: `POST https://{store_domain}/api/v1/store/loyalty/stamp/void`
4. Or void a points transaction: 4. Or void a points transaction:
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/points/void` - Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/points/void`
- Prod: `POST https://{store_domain}/api/store/loyalty/points/void` - Prod: `POST https://{store_domain}/api/v1/store/loyalty/points/void`
5. Verify: original and void transactions are linked in the audit log 5. Verify: original and void transactions are linked in the audit log
--- ---
@@ -751,28 +751,28 @@ flowchart TD
**Precondition:** Cross-location redemption must be enabled in merchant settings: **Precondition:** Cross-location redemption must be enabled in merchant settings:
- Dev: `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/1/settings` - Dev: `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/1/settings`
- Prod: `https://rewardflow.lu/admin/loyalty/merchants/1/settings` - Prod: `https://rewardflow.lu/admin/loyalty/merchants/1/settings`
**Steps:** **Steps:**
1. Staff at ORION adds stamps to customer's card: 1. Staff at ORION adds stamps to customer's card:
- Dev: `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/terminal` - Dev: `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/terminal`
- Prod: `https://{store_domain}/store/ORION/loyalty/terminal` - Prod: `https://{store_domain}/store/ORION/loyalty/terminal`
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/stamp` - Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/stamp`
- Prod: `POST https://{store_domain}/api/store/loyalty/stamp` - Prod: `POST https://{store_domain}/api/v1/store/loyalty/stamp`
2. Customer visits WIZAGADGETS 2. Customer visits WIZAGADGETS
3. Staff at WIZAGADGETS looks up the same card: 3. Staff at WIZAGADGETS looks up the same card:
- Dev: `http://localhost:9999/platforms/loyalty/store/WIZAGADGETS/loyalty/terminal` - Dev: `http://localhost:8000/platforms/loyalty/store/WIZAGADGETS/loyalty/terminal`
- Prod: `https://{store_domain}/store/WIZAGADGETS/loyalty/terminal` - Prod: `https://{store_domain}/store/WIZAGADGETS/loyalty/terminal`
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/cards/lookup` - Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/cards/lookup`
- Prod: `POST https://{store_domain}/api/store/loyalty/cards/lookup` - Prod: `POST https://{store_domain}/api/v1/store/loyalty/cards/lookup`
4. Card is found (same merchant) with accumulated stamps 4. Card is found (same merchant) with accumulated stamps
5. Staff at WIZAGADGETS redeems the reward: 5. Staff at WIZAGADGETS redeems the reward:
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/stamp/redeem` - Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/stamp/redeem`
- Prod: `POST https://{store_domain}/api/store/loyalty/stamp/redeem` - Prod: `POST https://{store_domain}/api/v1/store/loyalty/stamp/redeem`
6. Verify transaction history shows both stores: 6. Verify transaction history shows both stores:
- Dev: `http://localhost:9999/platforms/loyalty/store/WIZAGADGETS/loyalty/cards/{card_id}` - Dev: `http://localhost:8000/platforms/loyalty/store/WIZAGADGETS/loyalty/cards/{card_id}`
- Prod: `https://{store_domain}/store/WIZAGADGETS/loyalty/cards/{card_id}` - Prod: `https://{store_domain}/store/WIZAGADGETS/loyalty/cards/{card_id}`
--- ---

View File

@@ -259,3 +259,17 @@ def get_platform_stats(
): ):
"""Get platform-wide loyalty statistics.""" """Get platform-wide loyalty statistics."""
return program_service.get_platform_stats(db) return program_service.get_platform_stats(db)
# =============================================================================
# Wallet Integration Status
# =============================================================================
@router.get("/wallet-status")
def get_wallet_status(
current_user: User = Depends(get_current_admin_api),
db: Session = Depends(get_db),
):
"""Get wallet integration status for the platform."""
return program_service.get_wallet_integration_status(db)

View File

@@ -48,6 +48,34 @@ class AppleWalletService:
and config.apple_signer_key_path and config.apple_signer_key_path
) )
def validate_config(self) -> dict[str, Any]:
"""Validate Apple Wallet configuration."""
import os
result: dict[str, Any] = {
"configured": self.is_configured,
"pass_type_id": config.apple_pass_type_id,
"team_id": config.apple_team_id,
"credentials_valid": False,
"errors": [],
}
if not self.is_configured:
return result
for label, path in [
("WWDR certificate", config.apple_wwdr_cert_path),
("Signer certificate", config.apple_signer_cert_path),
("Signer key", config.apple_signer_key_path),
]:
if not os.path.isfile(path):
result["errors"].append(f"{label} not found: {path}")
if not result["errors"]:
result["credentials_valid"] = True
return result
# ========================================================================= # =========================================================================
# Auth Verification # Auth Verification
# ========================================================================= # =========================================================================
@@ -628,18 +656,71 @@ class AppleWalletService:
""" """
Send an empty push notification to trigger pass update. Send an empty push notification to trigger pass update.
Apple Wallet will then call our web service to fetch the updated pass. Apple Wallet will call our web service to fetch the updated pass.
Uses APNs HTTP/2 API with certificate-based authentication.
""" """
# This would use APNs to send the push notification if not self.is_configured:
# For now, we'll log and skip the actual push logger.debug("Apple Wallet not configured, skipping push")
logger.debug(f"Would send push to token {push_token[:8]}...") return
# In production, you would use something like: import ssl
# from apns2.client import APNsClient
# from apns2.payload import Payload import httpx
# client = APNsClient(config.apple_signer_cert_path, use_sandbox=True)
# payload = Payload() # APNs endpoint (use sandbox for dev, production for prod)
# client.send_notification(push_token, payload, "pass.com.example.loyalty") from app.core.config import is_production
if is_production():
apns_host = "https://api.push.apple.com"
else:
apns_host = "https://api.sandbox.push.apple.com"
url = f"{apns_host}/3/device/{push_token}"
# Create SSL context with client certificate
ssl_context = ssl.create_default_context()
ssl_context.load_cert_chain(
certfile=config.apple_signer_cert_path,
keyfile=config.apple_signer_key_path,
)
# APNs requires empty payload for pass updates
headers = {
"apns-topic": config.apple_pass_type_id,
"apns-push-type": "background",
"apns-priority": "5",
}
try:
with httpx.Client(
http2=True,
verify=ssl_context,
timeout=10.0,
) as client:
response = client.post(
url,
headers=headers,
content=b"{}",
)
if response.status_code == 200:
logger.debug(
"APNs push sent to token %s...", push_token[:8]
)
elif response.status_code == 410:
logger.info(
"APNs token %s... is no longer valid (device unregistered)",
push_token[:8],
)
else:
logger.warning(
"APNs push failed for token %s...: %s %s",
push_token[:8],
response.status_code,
response.text,
)
except Exception as exc: # noqa: BLE001
logger.error("APNs push error for token %s...: %s", push_token[:8], exc)
# Singleton instance # Singleton instance

View File

@@ -7,9 +7,14 @@ Handles Google Wallet integration including:
- Creating LoyaltyObject for cards - Creating LoyaltyObject for cards
- Updating objects on balance changes - Updating objects on balance changes
- Generating "Add to Wallet" URLs - Generating "Add to Wallet" URLs
- Startup config validation
- Retry logic for transient API failures
""" """
import json
import logging import logging
import time
from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -23,6 +28,51 @@ from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Retry configuration
MAX_RETRIES = 3
RETRY_BACKOFF_BASE = 1 # seconds
def _retry_on_failure(func):
"""Decorator that retries Google Wallet API calls on transient failures.
Only retries on 5xx/network errors. 4xx errors (bad request, not found)
are not retryable and fail immediately.
"""
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(MAX_RETRIES):
try:
return func(*args, **kwargs)
except WalletIntegrationException as exc:
last_exception = exc
# Don't retry client errors (400, 401, 403, 404, 409)
exc_msg = str(exc)
if any(f":{code}" in exc_msg or f" {code} " in exc_msg
for code in ("400", "401", "403", "404")):
logger.error("Google Wallet API client error (not retryable): %s", exc)
break
if attempt < MAX_RETRIES - 1:
wait = RETRY_BACKOFF_BASE * (2**attempt)
logger.warning(
"Google Wallet API failed (attempt %d/%d), retrying in %ds: %s",
attempt + 1,
MAX_RETRIES,
wait,
exc,
)
time.sleep(wait)
else:
logger.error(
"Google Wallet API failed after %d attempts: %s",
MAX_RETRIES,
exc,
)
raise last_exception # type: ignore[misc]
return wrapper
class GoogleWalletService: class GoogleWalletService:
"""Service for Google Wallet integration.""" """Service for Google Wallet integration."""
@@ -31,11 +81,70 @@ class GoogleWalletService:
"""Initialize the Google Wallet service.""" """Initialize the Google Wallet service."""
self._credentials = None self._credentials = None
self._http_client = None self._http_client = None
self._signer = None
@property @property
def is_configured(self) -> bool: def is_configured(self) -> bool:
"""Check if Google Wallet is configured.""" """Check if Google Wallet is configured."""
return bool(settings.loyalty_google_issuer_id and settings.loyalty_google_service_account_json) return bool(
settings.loyalty_google_issuer_id
and settings.loyalty_google_service_account_json
)
def validate_config(self) -> dict[str, Any]:
"""
Validate Google Wallet configuration at startup.
Returns:
Dict with validation results including any errors found.
"""
import os
result: dict[str, Any] = {
"configured": self.is_configured,
"issuer_id": settings.loyalty_google_issuer_id,
"service_account_path": settings.loyalty_google_service_account_json,
"credentials_valid": False,
"errors": [],
}
if not self.is_configured:
return result
sa_path = settings.loyalty_google_service_account_json
if not os.path.isfile(sa_path):
result["errors"].append(f"Service account file not found: {sa_path}")
return result
try:
with open(sa_path) as f:
sa_data = json.load(f)
required_fields = ["type", "project_id", "private_key", "client_email"]
for field in required_fields:
if field not in sa_data:
result["errors"].append(
f"Missing field in service account JSON: {field}"
)
if sa_data.get("type") != "service_account":
result["errors"].append(
f"Invalid credential type: {sa_data.get('type')} "
f"(expected 'service_account')"
)
if not result["errors"]:
self._get_credentials()
result["credentials_valid"] = True
result["service_account_email"] = sa_data.get("client_email")
result["project_id"] = sa_data.get("project_id")
except json.JSONDecodeError as exc:
result["errors"].append(f"Invalid JSON in service account file: {exc}")
except Exception as exc: # noqa: BLE001
result["errors"].append(f"Failed to load credentials: {exc}")
return result
def _get_credentials(self): def _get_credentials(self):
"""Get Google service account credentials.""" """Get Google service account credentials."""
@@ -50,14 +159,32 @@ class GoogleWalletService:
scopes = ["https://www.googleapis.com/auth/wallet_object.issuer"] scopes = ["https://www.googleapis.com/auth/wallet_object.issuer"]
self._credentials = service_account.Credentials.from_service_account_file( self._credentials = (
settings.loyalty_google_service_account_json, service_account.Credentials.from_service_account_file(
scopes=scopes, settings.loyalty_google_service_account_json,
scopes=scopes,
)
) )
return self._credentials return self._credentials
except (ValueError, OSError) as e: except (ValueError, OSError) as exc:
logger.error(f"Failed to load Google credentials: {e}") logger.error("Failed to load Google credentials: %s", exc)
raise WalletIntegrationException("google", str(e)) raise WalletIntegrationException("google", str(exc))
def _get_signer(self):
"""Get RSA signer from service account for JWT signing."""
if self._signer:
return self._signer
try:
from google.auth.crypt import RSASigner
self._signer = RSASigner.from_service_account_file(
settings.loyalty_google_service_account_json,
)
return self._signer
except Exception as exc: # noqa: BLE001
logger.error("Failed to create RSA signer: %s", exc)
raise WalletIntegrationException("google", str(exc))
def _get_http_client(self): def _get_http_client(self):
"""Get authenticated HTTP client.""" """Get authenticated HTTP client."""
@@ -70,14 +197,15 @@ class GoogleWalletService:
credentials = self._get_credentials() credentials = self._get_credentials()
self._http_client = AuthorizedSession(credentials) self._http_client = AuthorizedSession(credentials)
return self._http_client return self._http_client
except Exception as e: # noqa: EXC003 except Exception as exc: # noqa: BLE001
logger.error(f"Failed to create Google HTTP client: {e}") logger.error("Failed to create Google HTTP client: %s", exc)
raise WalletIntegrationException("google", str(e)) raise WalletIntegrationException("google", str(exc))
# ========================================================================= # =========================================================================
# LoyaltyClass Operations (Program-level) # LoyaltyClass Operations (Program-level)
# ========================================================================= # =========================================================================
@_retry_on_failure
def create_class(self, db: Session, program: LoyaltyProgram) -> str: def create_class(self, db: Session, program: LoyaltyProgram) -> str:
""" """
Create a LoyaltyClass for a loyalty program. Create a LoyaltyClass for a loyalty program.
@@ -95,17 +223,16 @@ class GoogleWalletService:
issuer_id = settings.loyalty_google_issuer_id issuer_id = settings.loyalty_google_issuer_id
class_id = f"{issuer_id}.loyalty_program_{program.id}" class_id = f"{issuer_id}.loyalty_program_{program.id}"
# issuerName is required by Google Wallet API
issuer_name = program.merchant.name if program.merchant else program.display_name
class_data = { class_data = {
"id": class_id, "id": class_id,
"issuerId": issuer_id, "issuerId": issuer_id,
"reviewStatus": "UNDER_REVIEW", "issuerName": issuer_name,
"reviewStatus": "DRAFT",
"programName": program.display_name, "programName": program.display_name,
"programLogo": { "hexBackgroundColor": program.card_color or "#4285F4",
"sourceUri": {
"uri": program.logo_url or "https://via.placeholder.com/100",
},
},
"hexBackgroundColor": program.card_color,
"localizedProgramName": { "localizedProgramName": {
"defaultValue": { "defaultValue": {
"language": "en", "language": "en",
@@ -114,6 +241,15 @@ class GoogleWalletService:
}, },
} }
# programLogo is required by Google Wallet API
# Google must be able to fetch the image, so it needs a public URL
logo_url = program.logo_url
if not logo_url:
logo_url = settings.loyalty_default_logo_url
class_data["programLogo"] = {
"sourceUri": {"uri": logo_url},
}
# Add hero image if configured # Add hero image if configured
if program.hero_image_url: if program.hero_image_url:
class_data["heroImage"] = { class_data["heroImage"] = {
@@ -128,14 +264,15 @@ class GoogleWalletService:
) )
if response.status_code in (200, 201): if response.status_code in (200, 201):
# Update program with class ID
program.google_class_id = class_id program.google_class_id = class_id
db.commit() db.commit()
logger.info(
logger.info(f"Created Google Wallet class {class_id} for program {program.id}") "Created Google Wallet class %s for program %s",
class_id,
program.id,
)
return class_id return class_id
if response.status_code == 409: if response.status_code == 409:
# Class already exists
program.google_class_id = class_id program.google_class_id = class_id
db.commit() db.commit()
return class_id return class_id
@@ -146,10 +283,11 @@ class GoogleWalletService:
) )
except WalletIntegrationException: except WalletIntegrationException:
raise raise
except Exception as e: # noqa: EXC003 except Exception as exc: # noqa: BLE001
logger.error(f"Failed to create Google Wallet class: {e}") logger.error("Failed to create Google Wallet class: %s", exc)
raise WalletIntegrationException("google", str(e)) raise WalletIntegrationException("google", str(exc))
@_retry_on_failure
def update_class(self, db: Session, program: LoyaltyProgram) -> None: def update_class(self, db: Session, program: LoyaltyProgram) -> None:
"""Update a LoyaltyClass when program settings change.""" """Update a LoyaltyClass when program settings change."""
if not program.google_class_id: if not program.google_class_id:
@@ -168,22 +306,25 @@ class GoogleWalletService:
try: try:
http = self._get_http_client() http = self._get_http_client()
response = http.patch( response = http.patch(
f"https://walletobjects.googleapis.com/walletobjects/v1/loyaltyClass/{program.google_class_id}", "https://walletobjects.googleapis.com/walletobjects/v1/loyaltyClass/"
f"{program.google_class_id}",
json=class_data, json=class_data,
) )
if response.status_code not in (200, 201): if response.status_code not in (200, 201):
logger.warning( logger.warning(
f"Failed to update Google Wallet class {program.google_class_id}: " "Failed to update Google Wallet class %s: %s",
f"{response.status_code}" program.google_class_id,
response.status_code,
) )
except Exception as e: # noqa: EXC003 except Exception as exc: # noqa: BLE001
logger.error(f"Failed to update Google Wallet class: {e}") logger.error("Failed to update Google Wallet class: %s", exc)
# ========================================================================= # =========================================================================
# LoyaltyObject Operations (Card-level) # LoyaltyObject Operations (Card-level)
# ========================================================================= # =========================================================================
@_retry_on_failure
def create_object(self, db: Session, card: LoyaltyCard) -> str: def create_object(self, db: Session, card: LoyaltyCard) -> str:
""" """
Create a LoyaltyObject for a loyalty card. Create a LoyaltyObject for a loyalty card.
@@ -200,7 +341,6 @@ class GoogleWalletService:
program = card.program program = card.program
if not program.google_class_id: if not program.google_class_id:
# Create class first
self.create_class(db, program) self.create_class(db, program)
issuer_id = settings.loyalty_google_issuer_id issuer_id = settings.loyalty_google_issuer_id
@@ -218,11 +358,13 @@ class GoogleWalletService:
if response.status_code in (200, 201): if response.status_code in (200, 201):
card.google_object_id = object_id card.google_object_id = object_id
db.commit() db.commit()
logger.info(
logger.info(f"Created Google Wallet object {object_id} for card {card.id}") "Created Google Wallet object %s for card %s",
object_id,
card.id,
)
return object_id return object_id
if response.status_code == 409: if response.status_code == 409:
# Object already exists
card.google_object_id = object_id card.google_object_id = object_id
db.commit() db.commit()
return object_id return object_id
@@ -233,10 +375,11 @@ class GoogleWalletService:
) )
except WalletIntegrationException: except WalletIntegrationException:
raise raise
except Exception as e: # noqa: EXC003 except Exception as exc: # noqa: BLE001
logger.error(f"Failed to create Google Wallet object: {e}") logger.error("Failed to create Google Wallet object: %s", exc)
raise WalletIntegrationException("google", str(e)) raise WalletIntegrationException("google", str(exc))
@_retry_on_failure
def update_object(self, db: Session, card: LoyaltyCard) -> None: def update_object(self, db: Session, card: LoyaltyCard) -> None:
"""Update a LoyaltyObject when card balance changes.""" """Update a LoyaltyObject when card balance changes."""
if not card.google_object_id: if not card.google_object_id:
@@ -247,25 +390,31 @@ class GoogleWalletService:
try: try:
http = self._get_http_client() http = self._get_http_client()
response = http.patch( response = http.patch(
f"https://walletobjects.googleapis.com/walletobjects/v1/loyaltyObject/{card.google_object_id}", "https://walletobjects.googleapis.com/walletobjects/v1/loyaltyObject/"
f"{card.google_object_id}",
json=object_data, json=object_data,
) )
if response.status_code in (200, 201): if response.status_code in (200, 201):
logger.debug(f"Updated Google Wallet object for card {card.id}") logger.debug(
"Updated Google Wallet object for card %s", card.id
)
else: else:
logger.warning( logger.warning(
f"Failed to update Google Wallet object {card.google_object_id}: " "Failed to update Google Wallet object %s: %s",
f"{response.status_code}" card.google_object_id,
response.status_code,
) )
except Exception as e: # noqa: EXC003 except Exception as exc: # noqa: BLE001
logger.error(f"Failed to update Google Wallet object: {e}") logger.error("Failed to update Google Wallet object: %s", exc)
def _build_object_data(self, card: LoyaltyCard, object_id: str) -> dict[str, Any]: def _build_object_data(
self, card: LoyaltyCard, object_id: str
) -> dict[str, Any]:
"""Build the LoyaltyObject data structure.""" """Build the LoyaltyObject data structure."""
program = card.program program = card.program
object_data = { object_data: dict[str, Any] = {
"id": object_id, "id": object_id,
"classId": program.google_class_id, "classId": program.google_class_id,
"state": "ACTIVE" if card.is_active else "INACTIVE", "state": "ACTIVE" if card.is_active else "INACTIVE",
@@ -278,7 +427,6 @@ class GoogleWalletService:
}, },
} }
# Add loyalty points (stamps as points for display)
if program.is_stamps_enabled: if program.is_stamps_enabled:
object_data["loyaltyPoints"] = { object_data["loyaltyPoints"] = {
"label": "Stamps", "label": "Stamps",
@@ -286,7 +434,6 @@ class GoogleWalletService:
"int": card.stamp_count, "int": card.stamp_count,
}, },
} }
# Add secondary points showing target
object_data["secondaryLoyaltyPoints"] = { object_data["secondaryLoyaltyPoints"] = {
"label": f"of {program.stamps_target}", "label": f"of {program.stamps_target}",
"balance": { "balance": {
@@ -311,6 +458,9 @@ class GoogleWalletService:
""" """
Get the "Add to Google Wallet" URL for a card. Get the "Add to Google Wallet" URL for a card.
Uses google.auth.crypt.RSASigner (public API) for JWT signing
instead of accessing private signer internals.
Args: Args:
db: Database session db: Database session
card: Loyalty card card: Loyalty card
@@ -321,34 +471,34 @@ class GoogleWalletService:
if not self.is_configured: if not self.is_configured:
raise GoogleWalletNotConfiguredException() raise GoogleWalletNotConfiguredException()
# Ensure object exists
if not card.google_object_id: if not card.google_object_id:
self.create_object(db, card) self.create_object(db, card)
# Generate JWT for save link
try: try:
from datetime import datetime, timedelta
import jwt import jwt
credentials = self._get_credentials() credentials = self._get_credentials()
signer = self._get_signer()
now = datetime.now(tz=UTC)
origins = settings.loyalty_google_wallet_origins or []
claims = { claims = {
"iss": credentials.service_account_email, "iss": credentials.service_account_email,
"aud": "google", "aud": "google",
"origins": [], "origins": origins,
"typ": "savetowallet", "typ": "savetowallet",
"payload": { "payload": {
"loyaltyObjects": [{"id": card.google_object_id}], "loyaltyObjects": [{"id": card.google_object_id}],
}, },
"iat": datetime.utcnow(), "iat": now,
"exp": datetime.utcnow() + timedelta(hours=1), "exp": now + timedelta(hours=1),
} }
# Sign with service account private key # Sign using the RSASigner's key_id and key bytes (public API)
token = jwt.encode( token = jwt.encode(
claims, claims,
credentials._signer._key, signer.key,
algorithm="RS256", algorithm="RS256",
) )
@@ -356,9 +506,49 @@ class GoogleWalletService:
db.commit() db.commit()
return f"https://pay.google.com/gp/v/save/{token}" return f"https://pay.google.com/gp/v/save/{token}"
except Exception as e: # noqa: EXC003 except Exception as exc: # noqa: BLE001
logger.error(f"Failed to generate Google Wallet save URL: {e}") logger.error(
raise WalletIntegrationException("google", str(e)) "Failed to generate Google Wallet save URL: %s", exc
)
raise WalletIntegrationException("google", str(exc))
# =========================================================================
# Class Approval
# =========================================================================
def get_class_status(self, class_id: str) -> dict[str, Any] | None:
"""
Check the review status of a LoyaltyClass.
Args:
class_id: Google Wallet class ID
Returns:
Dict with class status info or None if not found.
"""
if not self.is_configured:
return None
try:
http = self._get_http_client()
response = http.get(
"https://walletobjects.googleapis.com/walletobjects/v1/loyaltyClass/"
f"{class_id}",
)
if response.status_code == 200:
data = response.json()
return {
"class_id": class_id,
"review_status": data.get("reviewStatus"),
"program_name": data.get("programName"),
}
return None
except Exception as exc: # noqa: BLE001
logger.error(
"Failed to get Google Wallet class status: %s", exc
)
return None
# Singleton instance # Singleton instance

View File

@@ -833,6 +833,62 @@ class ProgramService:
"estimated_liability_cents": estimated_liability, "estimated_liability_cents": estimated_liability,
} }
def get_wallet_integration_status(self, db: Session) -> dict:
"""Get wallet integration status for admin dashboard."""
from app.modules.loyalty.models import LoyaltyCard
from app.modules.loyalty.services.apple_wallet_service import (
apple_wallet_service,
)
from app.modules.loyalty.services.google_wallet_service import (
google_wallet_service,
)
# Google Wallet
google_config = google_wallet_service.validate_config()
google_classes = []
if google_config["credentials_valid"]:
programs_with_class = (
db.query(LoyaltyProgram)
.filter(LoyaltyProgram.google_class_id.isnot(None))
.all()
)
for prog in programs_with_class:
status = google_wallet_service.get_class_status(
prog.google_class_id,
)
google_classes.append({
"program_id": prog.id,
"program_name": prog.display_name,
"class_id": prog.google_class_id,
"review_status": status["review_status"] if status else "UNKNOWN",
})
google_objects = (
db.query(LoyaltyCard)
.filter(LoyaltyCard.google_object_id.isnot(None))
.count()
)
# Apple Wallet
apple_config = apple_wallet_service.validate_config()
apple_passes = (
db.query(LoyaltyCard)
.filter(LoyaltyCard.apple_serial_number.isnot(None))
.count()
)
return {
"google_wallet": {
**google_config,
"classes": google_classes,
"total_objects": google_objects,
},
"apple_wallet": {
**apple_config,
"total_passes": apple_passes,
},
}
def get_merchant_stats(self, db: Session, merchant_id: int) -> dict: def get_merchant_stats(self, db: Session, merchant_id: int) -> dict:
""" """
Get statistics for a merchant's loyalty program across all locations. Get statistics for a merchant's loyalty program across all locations.

View File

@@ -33,6 +33,10 @@ function adminLoyaltyAnalytics() {
showMerchantDropdown: false, showMerchantDropdown: false,
searchingMerchants: false, searchingMerchants: false,
// Wallet integration status
walletStatus: null,
walletStatusLoading: false,
loading: false, loading: false,
error: null, error: null,
@@ -59,6 +63,7 @@ function adminLoyaltyAnalytics() {
window._loyaltyAnalyticsInitialized = true; window._loyaltyAnalyticsInitialized = true;
await this.loadStats(); await this.loadStats();
await this.loadWalletStatus();
loyaltyAnalyticsLog.info('=== LOYALTY ANALYTICS PAGE INITIALIZATION COMPLETE ==='); loyaltyAnalyticsLog.info('=== LOYALTY ANALYTICS PAGE INITIALIZATION COMPLETE ===');
}, },
@@ -166,6 +171,21 @@ function adminLoyaltyAnalytics() {
await this.loadStats(); await this.loadStats();
}, },
async loadWalletStatus() {
this.walletStatusLoading = true;
try {
const response = await apiClient.get('/admin/loyalty/wallet-status');
if (response) {
this.walletStatus = response;
loyaltyAnalyticsLog.info('Wallet status loaded');
}
} catch (error) {
loyaltyAnalyticsLog.error('Failed to load wallet status:', error);
} finally {
this.walletStatusLoading = false;
}
},
formatNumber(num) { formatNumber(num) {
if (num === null || num === undefined) return '0'; if (num === null || num === undefined) return '0';
return new Intl.NumberFormat('en-US').format(num); return new Intl.NumberFormat('en-US').format(num);

View File

@@ -100,6 +100,7 @@ function adminLoyaltyProgramEdit() {
try { try {
const payload = this.buildPayload(); const payload = this.buildPayload();
if (!payload) { this.saving = false; return; }
if (this.isNewProgram) { if (this.isNewProgram) {
const response = await apiClient.post( const response = await apiClient.post(

View File

@@ -52,6 +52,7 @@ function merchantLoyaltySettings() {
try { try {
const payload = this.buildPayload(); const payload = this.buildPayload();
if (!payload) { this.saving = false; return; }
if (this.isNewProgram) { if (this.isNewProgram) {
await apiClient.post('/merchants/loyalty/program', payload); await apiClient.post('/merchants/loyalty/program', payload);

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -98,7 +98,10 @@ function createProgramFormMixin() {
if (!payload.minimum_purchase_cents) payload.minimum_purchase_cents = null; if (!payload.minimum_purchase_cents) payload.minimum_purchase_cents = null;
if (!payload.card_name) payload.card_name = null; if (!payload.card_name) payload.card_name = null;
if (!payload.card_secondary_color) payload.card_secondary_color = null; if (!payload.card_secondary_color) payload.card_secondary_color = null;
if (!payload.logo_url) payload.logo_url = null; if (!payload.logo_url) {
this.error = 'Logo URL is required for wallet integration.';
return null;
}
if (!payload.hero_image_url) payload.hero_image_url = null; if (!payload.hero_image_url) payload.hero_image_url = null;
if (!payload.terms_text) payload.terms_text = null; if (!payload.terms_text) payload.terms_text = null;
if (!payload.privacy_url) payload.privacy_url = null; if (!payload.privacy_url) payload.privacy_url = null;

View File

@@ -82,6 +82,7 @@ function loyaltySettings() {
try { try {
const payload = this.buildPayload(); const payload = this.buildPayload();
if (!payload) { this.saving = false; return; }
let response; let response;
if (this.isNewProgram) { if (this.isNewProgram) {

View File

@@ -25,6 +25,7 @@ function customerLoyaltyEnroll() {
enrolled: false, enrolled: false,
enrolledCard: null, enrolledCard: null,
error: null, error: null,
showTerms: false,
async init() { async init() {
console.log('Customer loyalty enroll initializing...'); console.log('Customer loyalty enroll initializing...');
@@ -73,8 +74,13 @@ function customerLoyaltyEnroll() {
if (response) { if (response) {
const cardNumber = response.card?.card_number || response.card_number; const cardNumber = response.card?.card_number || response.card_number;
console.log('Enrollment successful:', cardNumber); console.log('Enrollment successful:', cardNumber);
// Redirect to success page - extract base path from current URL
// Current page is at /storefront/loyalty/join, redirect to /storefront/loyalty/join/success // Store wallet URLs for the success page (no auth needed)
if (response.wallet_urls) {
sessionStorage.setItem('loyalty_wallet_urls', JSON.stringify(response.wallet_urls));
}
// Redirect to success page
const currentPath = window.location.pathname; const currentPath = window.location.pathname;
const successUrl = currentPath.replace(/\/join\/?$/, '/join/success') + const successUrl = currentPath.replace(/\/join\/?$/, '/join/success') +
'?card=' + encodeURIComponent(cardNumber); '?card=' + encodeURIComponent(cardNumber);

View File

@@ -50,6 +50,109 @@
{% set show_merchants_metric = true %} {% set show_merchants_metric = true %}
{% include "loyalty/shared/analytics-stats.html" %} {% include "loyalty/shared/analytics-stats.html" %}
<!-- Wallet Integration Status -->
<div class="mb-6 px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800" x-show="walletStatus">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
<span x-html="$icon('device-phone-mobile', 'w-5 h-5 inline mr-1')"></span>
Wallet Integration Status
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Google Wallet -->
<div class="p-4 border rounded-lg dark:border-gray-700" x-show="walletStatus?.google_wallet">
<div class="flex items-center justify-between mb-3">
<h4 class="font-medium text-gray-700 dark:text-gray-300">Google Wallet</h4>
<template x-if="walletStatus?.google_wallet?.credentials_valid">
<span class="px-2 py-1 text-xs font-medium text-green-700 bg-green-100 rounded-full dark:text-green-300 dark:bg-green-900/30">Connected</span>
</template>
<template x-if="walletStatus?.google_wallet?.configured && !walletStatus?.google_wallet?.credentials_valid">
<span class="px-2 py-1 text-xs font-medium text-red-700 bg-red-100 rounded-full dark:text-red-300 dark:bg-red-900/30">Error</span>
</template>
<template x-if="!walletStatus?.google_wallet?.configured">
<span class="px-2 py-1 text-xs font-medium text-gray-500 bg-gray-100 rounded-full dark:text-gray-400 dark:bg-gray-700">Not Configured</span>
</template>
</div>
<template x-if="walletStatus?.google_wallet?.configured">
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Issuer ID</span>
<span class="text-gray-700 dark:text-gray-300" x-text="walletStatus.google_wallet.issuer_id"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Project</span>
<span class="text-gray-700 dark:text-gray-300" x-text="walletStatus.google_wallet.project_id || '-'"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Wallet Objects</span>
<span class="font-medium text-gray-700 dark:text-gray-300" x-text="walletStatus.google_wallet.total_objects || 0"></span>
</div>
<!-- Class statuses -->
<template x-if="walletStatus.google_wallet.classes?.length > 0">
<div class="mt-2 pt-2 border-t dark:border-gray-700">
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Loyalty Classes</p>
<template x-for="cls in walletStatus.google_wallet.classes" :key="cls.class_id">
<div class="flex justify-between text-xs py-1">
<span class="text-gray-600 dark:text-gray-400" x-text="cls.program_name"></span>
<span class="px-1.5 py-0.5 rounded"
:class="cls.review_status === 'APPROVED' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300' : cls.review_status === 'DRAFT' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'"
x-text="cls.review_status"></span>
</div>
</template>
</div>
</template>
<!-- Errors -->
<template x-if="walletStatus.google_wallet.errors?.length > 0">
<div class="mt-2 pt-2 border-t dark:border-gray-700">
<template x-for="err in walletStatus.google_wallet.errors" :key="err">
<p class="text-xs text-red-600 dark:text-red-400" x-text="err"></p>
</template>
</div>
</template>
</div>
</template>
</div>
<!-- Apple Wallet -->
<div class="p-4 border rounded-lg dark:border-gray-700" x-show="walletStatus?.apple_wallet">
<div class="flex items-center justify-between mb-3">
<h4 class="font-medium text-gray-700 dark:text-gray-300">Apple Wallet</h4>
<template x-if="walletStatus?.apple_wallet?.credentials_valid">
<span class="px-2 py-1 text-xs font-medium text-green-700 bg-green-100 rounded-full dark:text-green-300 dark:bg-green-900/30">Connected</span>
</template>
<template x-if="walletStatus?.apple_wallet?.configured && !walletStatus?.apple_wallet?.credentials_valid">
<span class="px-2 py-1 text-xs font-medium text-red-700 bg-red-100 rounded-full dark:text-red-300 dark:bg-red-900/30">Error</span>
</template>
<template x-if="!walletStatus?.apple_wallet?.configured">
<span class="px-2 py-1 text-xs font-medium text-gray-500 bg-gray-100 rounded-full dark:text-gray-400 dark:bg-gray-700">Not Configured</span>
</template>
</div>
<template x-if="walletStatus?.apple_wallet?.configured">
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Pass Type ID</span>
<span class="text-gray-700 dark:text-gray-300" x-text="walletStatus.apple_wallet.pass_type_id"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Team ID</span>
<span class="text-gray-700 dark:text-gray-300" x-text="walletStatus.apple_wallet.team_id"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Active Passes</span>
<span class="font-medium text-gray-700 dark:text-gray-300" x-text="walletStatus.apple_wallet.total_passes || 0"></span>
</div>
<template x-if="walletStatus.apple_wallet.errors?.length > 0">
<div class="mt-2 pt-2 border-t dark:border-gray-700">
<template x-for="err in walletStatus.apple_wallet.errors" :key="err">
<p class="text-xs text-red-600 dark:text-red-400" x-text="err"></p>
</template>
</div>
</template>
</div>
</template>
</div>
</div>
</div>
<!-- Quick Actions --> <!-- Quick Actions -->
<div class="px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800"> <div class="px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">Quick Actions</h3> <h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">Quick Actions</h3>

View File

@@ -226,9 +226,10 @@
</div> </div>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Logo URL</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Logo URL <span class="text-red-500">*</span></label>
<input type="url" x-model="settings.logo_url" maxlength="500" placeholder="https://..." <input type="url" x-model="settings.logo_url" maxlength="500" placeholder="https://..." required
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"> 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">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Required for Google Wallet integration. Must be a publicly accessible image URL (PNG or JPG).</p>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Hero Image URL</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Hero Image URL</label>

View File

@@ -24,7 +24,8 @@
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2">Your Card Number</p> <p class="text-sm text-gray-500 dark:text-gray-400 mb-2">Your Card Number</p>
<p class="text-2xl font-mono font-bold text-gray-900 dark:text-white">{{ enrolled_card_number or 'Loading...' }}</p> <p class="text-2xl font-mono font-bold text-gray-900 dark:text-white">{{ enrolled_card_number or 'Loading...' }}</p>
<div class="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700"> <div x-show="walletUrls.apple_wallet_url || walletUrls.google_wallet_url"
class="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4"> <p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Save your card to your phone for easy access: Save your card to your phone for easy access:
</p> </p>
@@ -88,14 +89,15 @@ function customerLoyaltyEnrollSuccess() {
return { return {
...storefrontLayoutData(), ...storefrontLayoutData(),
walletUrls: { google_wallet_url: null, apple_wallet_url: null }, walletUrls: { google_wallet_url: null, apple_wallet_url: null },
async init() { init() {
// Read wallet URLs saved during enrollment (no auth needed)
try { try {
const response = await apiClient.get('/storefront/loyalty/card'); const stored = sessionStorage.getItem('loyalty_wallet_urls');
if (response && response.wallet_urls) { if (stored) {
this.walletUrls = response.wallet_urls; this.walletUrls = JSON.parse(stored);
sessionStorage.removeItem('loyalty_wallet_urls');
} }
} catch (e) { } catch (e) {
// Customer may not be authenticated (public enrollment)
console.log('Could not load wallet URLs:', e.message); console.log('Could not load wallet URLs:', e.message);
} }
} }

View File

@@ -93,7 +93,13 @@
class="mt-1 w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary" class="mt-1 w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
style="color: var(--color-primary)"> style="color: var(--color-primary)">
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400"> <span class="ml-2 text-sm text-gray-600 dark:text-gray-400">
I agree to the <a href="#" class="underline" style="color: var(--color-primary)">Terms & Conditions</a> I agree to the
<template x-if="program?.terms_text">
<a href="#" @click.prevent="showTerms = true" class="underline" style="color: var(--color-primary)">Terms & Conditions</a>
</template>
<template x-if="!program?.terms_text">
<span class="underline" style="color: var(--color-primary)">Terms & Conditions</span>
</template>
</span> </span>
</label> </label>
<label class="flex items-start"> <label class="flex items-start">
@@ -128,6 +134,37 @@
</div> </div>
</div> </div>
</div> </div>
{# TODO: Rework T&C strategy - current approach (small text field on program model) won't scale
for full legal T&C. Options: (1) leverage the CMS module to host T&C pages, or
(2) create a dedicated T&C page within the loyalty module. Decision pending. #}
<!-- Terms & Conditions Modal -->
<div x-show="showTerms" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50"
@click.self="showTerms = false"
@keydown.escape.window="showTerms = false">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-lg w-full max-h-[80vh] flex flex-col">
<div class="flex items-center justify-between p-4 border-b dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Terms & Conditions</h3>
<button @click="showTerms = false" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<span x-html="$icon('x-mark', 'w-5 h-5')"></span>
</button>
</div>
<div class="p-4 overflow-y-auto text-sm text-gray-700 dark:text-gray-300 whitespace-pre-line" x-text="program?.terms_text"></div>
<template x-if="program?.privacy_url">
<div class="px-4 pb-2">
<a :href="program.privacy_url" target="_blank" class="text-sm underline" style="color: var(--color-primary)">Privacy Policy</a>
</div>
</template>
<div class="p-4 border-t dark:border-gray-700">
<button @click="showTerms = false"
class="w-full py-2 px-4 text-white font-medium rounded-lg"
:style="'background-color: ' + (program?.card_color || 'var(--color-primary)')">
Close
</button>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block extra_scripts %} {% block extra_scripts %}

View File

@@ -196,7 +196,7 @@ class TestResolveCustomerId:
assert customer.first_name == "Jane" assert customer.first_name == "Jane"
assert customer.last_name == "Doe" assert customer.last_name == "Doe"
assert customer.phone == "+352123456" assert customer.phone == "+352123456"
assert customer.hashed_password.startswith("!loyalty-enroll!") assert customer.hashed_password.startswith("!enrollment!")
assert customer.customer_number is not None assert customer.customer_number is not None
assert customer.is_active is True assert customer.is_active is True

View File

@@ -75,6 +75,9 @@
<!-- Quill CSS with CDN fallback (loaded on demand via block) --> <!-- Quill CSS with CDN fallback (loaded on demand via block) -->
{% block quill_css %}{% endblock %} {% block quill_css %}{% endblock %}
<!-- Dev debug toolbar (dev only — auto-hides in production, must load early for interceptors) -->
<script src="{{ url_for('static', path='shared/js/dev-toolbar.js') }}"></script>
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}
</head> </head>
<body x-cloak> <body x-cloak>

View File

@@ -25,6 +25,9 @@
[x-cloak] { display: none !important; } [x-cloak] { display: none !important; }
</style> </style>
<!-- Dev debug toolbar (dev only — auto-hides in production, must load early for interceptors) -->
<script src="{{ url_for('static', path='shared/js/dev-toolbar.js') }}"></script>
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}
</head> </head>
<body x-cloak> <body x-cloak>

View File

@@ -50,6 +50,9 @@
{# Base Shop Styles #} {# Base Shop Styles #}
<link rel="stylesheet" href="{{ url_for('static', path='storefront/css/storefront.css') }}"> <link rel="stylesheet" href="{{ url_for('static', path='storefront/css/storefront.css') }}">
{# Dev debug toolbar (dev only — auto-hides in production, must load early for interceptors) #}
<script src="{{ url_for('static', path='shared/js/dev-toolbar.js') }}"></script>
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}
</head> </head>