feat(loyalty): wallet debug page, Google Wallet fixes, and module config env_file standardization
Some checks failed
CI / ruff (push) Successful in 12s
CI / validate (push) Successful in 27s
CI / dependency-scanning (push) Successful in 32s
CI / pytest (push) Failing after 1h13m39s
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled

- Add wallet diagnostics page at /admin/loyalty/wallet-debug (super admin only)
  with explorer-sidebar pattern: config validation, class status, card inspector,
  save URL tester, recent enrollments, and Apple Wallet status panels
- Fix Google Wallet fat JWT: include both loyaltyClasses and loyaltyObjects in
  payload, use UNDER_REVIEW instead of DRAFT for class reviewStatus
- Fix StorefrontProgramResponse schema: accept google_class_id values while
  keeping exclude=True (was rejecting non-None values)
- Standardize all module configs to read from .env file directly
  (env_file=".env", extra="ignore") matching core Settings pattern
- Add MOD-026 architecture rule enforcing env_file in module configs
- Add SVC-005 noqa support in architecture validator
- Add test files for dev_tools domain_health and isolation_audit services
- Add google_wallet_status.py script for querying Google Wallet API
- Use table_wrapper macro in wallet-debug.html (FE-005 compliance)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 22:18:39 +01:00
parent 11b8e31a29
commit f89c0382f0
31 changed files with 1721 additions and 64 deletions

View File

@@ -23,7 +23,7 @@ class ModuleConfig(BaseSettings):
# api_timeout: int = 30
# batch_size: int = 100
model_config = {"env_prefix": "ANALYTICS_"}
model_config = {"env_prefix": "ANALYTICS_", "env_file": ".env", "extra": "ignore"}
# Export for auto-discovery

View File

@@ -23,7 +23,7 @@ class ModuleConfig(BaseSettings):
# api_timeout: int = 30
# batch_size: int = 100
model_config = {"env_prefix": "BILLING_"}
model_config = {"env_prefix": "BILLING_", "env_file": ".env", "extra": "ignore"}
# Export for auto-discovery

View File

@@ -23,7 +23,7 @@ class ModuleConfig(BaseSettings):
# api_timeout: int = 30
# batch_size: int = 100
model_config = {"env_prefix": "CMS_"}
model_config = {"env_prefix": "CMS_", "env_file": ".env", "extra": "ignore"}
# Export for auto-discovery

View File

@@ -24,7 +24,7 @@ Config File Pattern:
api_timeout: int = Field(default=30, description="API timeout in seconds")
max_retries: int = Field(default=3, description="Max retry attempts")
model_config = {"env_prefix": "MYMODULE_"}
model_config = {"env_prefix": "MYMODULE_", "env_file": ".env", "extra": "ignore"}
# Export the config class and instance
config_class = MyModuleConfig

View File

@@ -23,7 +23,7 @@ class ModuleConfig(BaseSettings):
# api_timeout: int = 30
# batch_size: int = 100
model_config = {"env_prefix": "CUSTOMERS_"}
model_config = {"env_prefix": "CUSTOMERS_", "env_file": ".env", "extra": "ignore"}
# Export for auto-discovery

View File

@@ -23,7 +23,7 @@ class ModuleConfig(BaseSettings):
# api_timeout: int = 30
# batch_size: int = 100
model_config = {"env_prefix": "DEV_TOOLS_"}
model_config = {"env_prefix": "DEV_TOOLS_", "env_file": ".env", "extra": "ignore"}
# Export for auto-discovery

View File

@@ -33,7 +33,7 @@ def run_domain_health_check(db: Session) -> dict:
# Pre-fetch all active stores, platforms, and memberships
active_stores = (
db.query(Store).filter(Store.is_active.is_(True)).all()
db.query(Store).filter(Store.is_active.is_(True)).all() # noqa: SVC-005
)
store_by_id: dict[int, Store] = {s.id: s for s in active_stores}

View File

@@ -0,0 +1,70 @@
# app/modules/dev_tools/tests/unit/test_domain_health_service.py
"""Unit tests for domain_health_service."""
import pytest
from app.modules.dev_tools.services.domain_health_service import _entry
@pytest.mark.unit
@pytest.mark.dev
class TestEntry:
"""Tests for the _entry helper function."""
def test_entry_pass(self):
"""Passing entry has status 'pass'."""
class FakeStore:
store_code = "ACME"
subdomain = "acme"
result = _entry(
domain="acme.rewardflow.lu",
entry_type="custom subdomain",
platform_code="RF",
expected_store=FakeStore(),
resolved_store=FakeStore(),
passed=True,
note="",
)
assert result["status"] == "pass"
assert result["domain"] == "acme.rewardflow.lu"
assert result["type"] == "custom subdomain"
assert result["platform_code"] == "RF"
assert result["expected_store"] == "ACME"
assert result["resolved_store"] == "ACME"
def test_entry_fail(self):
"""Failing entry has status 'fail' and includes note."""
class FakeStore:
store_code = "ACME"
subdomain = "acme"
result = _entry(
domain="acme.rewardflow.lu",
entry_type="custom subdomain",
platform_code="RF",
expected_store=FakeStore(),
resolved_store=None,
passed=False,
note="custom_subdomain lookup failed",
)
assert result["status"] == "fail"
assert result["resolved_store"] is None
assert result["note"] == "custom_subdomain lookup failed"
def test_entry_none_stores(self):
"""Entry handles None expected/resolved stores."""
result = _entry(
domain="test.example.com",
entry_type="custom domain",
platform_code=None,
expected_store=None,
resolved_store=None,
passed=False,
note="not found",
)
assert result["expected_store"] is None
assert result["resolved_store"] is None
assert result["platform_code"] is None

View File

@@ -0,0 +1,193 @@
# app/modules/dev_tools/tests/unit/test_isolation_audit_service.py
"""Unit tests for isolation_audit_service."""
from types import SimpleNamespace
import pytest
from app.modules.dev_tools.services.isolation_audit_service import (
_check_contact_inheritance,
_check_language_config,
_check_locale_fallback,
_check_merchant_active,
_check_merchant_domain_primary,
_check_theme_fallback,
_finding,
)
def _make_store(**kwargs):
"""Create a minimal fake store for testing."""
defaults = {
"contact_email": None,
"contact_phone": None,
"website": None,
"business_address": None,
"tax_number": None,
"merchant": None,
"store_platforms": [],
"domains": [],
"store_theme": None,
"storefront_locale": None,
"storefront_languages": None,
}
defaults.update(kwargs)
return SimpleNamespace(**defaults)
def _make_merchant(**kwargs):
defaults = {"name": "TestMerchant", "is_active": True}
defaults.update({k: None for k in ["contact_email", "contact_phone", "website", "business_address", "tax_number"]})
defaults.update(kwargs)
return SimpleNamespace(**defaults)
@pytest.mark.unit
@pytest.mark.dev
class TestFinding:
"""Tests for the _finding helper."""
def test_finding_structure(self):
result = _finding(
check="test",
check_label="Test",
risk="high",
resolved_value="val",
source="store",
source_label="Store value",
is_explicit=True,
note="a note",
)
assert result["check"] == "test"
assert result["risk"] == "high"
assert result["is_explicit"] is True
assert result["note"] == "a note"
@pytest.mark.unit
@pytest.mark.dev
class TestCheckContactInheritance:
"""Tests for _check_contact_inheritance."""
def test_no_findings_when_store_has_values(self):
store = _make_store(
contact_email="shop@test.com",
contact_phone="+352123",
website="https://test.com",
business_address="123 Main St",
tax_number="LU12345",
merchant=_make_merchant(),
)
assert _check_contact_inheritance(store) == []
def test_critical_when_inheriting_from_merchant(self):
merchant = _make_merchant(contact_email="merchant@test.com")
store = _make_store(merchant=merchant)
findings = _check_contact_inheritance(store)
email_findings = [f for f in findings if f["check"] == "contact_email"]
assert len(email_findings) == 1
assert email_findings[0]["risk"] == "critical"
assert email_findings[0]["source"] == "merchant"
def test_critical_when_no_value_anywhere(self):
store = _make_store(merchant=_make_merchant())
findings = _check_contact_inheritance(store)
email_findings = [f for f in findings if f["check"] == "contact_email"]
assert len(email_findings) == 1
assert email_findings[0]["source"] == "none"
@pytest.mark.unit
@pytest.mark.dev
class TestCheckMerchantActive:
"""Tests for _check_merchant_active."""
def test_no_finding_when_active(self):
store = _make_store(merchant=_make_merchant(is_active=True))
assert _check_merchant_active(store) == []
def test_finding_when_inactive(self):
store = _make_store(merchant=_make_merchant(is_active=False))
findings = _check_merchant_active(store)
assert len(findings) == 1
assert findings[0]["risk"] == "high"
@pytest.mark.unit
@pytest.mark.dev
class TestCheckMerchantDomainPrimary:
"""Tests for _check_merchant_domain_primary."""
def test_no_finding_single_primary(self):
domains = [SimpleNamespace(domain="test.com", is_primary=True, is_active=True)]
assert _check_merchant_domain_primary(domains) == []
def test_finding_multiple_primaries(self):
domains = [
SimpleNamespace(domain="a.com", is_primary=True, is_active=True),
SimpleNamespace(domain="b.com", is_primary=True, is_active=True),
]
findings = _check_merchant_domain_primary(domains)
assert len(findings) == 1
assert findings[0]["risk"] == "high"
@pytest.mark.unit
@pytest.mark.dev
class TestCheckThemeFallback:
"""Tests for _check_theme_fallback."""
def test_no_finding_with_active_theme(self):
store = _make_store(store_theme=SimpleNamespace(is_active=True))
assert _check_theme_fallback(store) == []
def test_finding_no_theme(self):
store = _make_store(store_theme=None)
findings = _check_theme_fallback(store)
assert len(findings) == 1
assert findings[0]["risk"] == "medium"
@pytest.mark.unit
@pytest.mark.dev
class TestCheckLocaleFallback:
"""Tests for _check_locale_fallback."""
def test_no_finding_with_locale(self):
store = _make_store(storefront_locale="de-LU")
assert _check_locale_fallback(store, "fr-LU") == []
def test_finding_platform_default(self):
store = _make_store(storefront_locale=None)
findings = _check_locale_fallback(store, "de-LU")
assert len(findings) == 1
assert findings[0]["resolved_value"] == "de-LU"
assert findings[0]["source"] == "platform_default"
def test_finding_hardcoded_fallback(self):
store = _make_store(storefront_locale=None)
findings = _check_locale_fallback(store, None)
assert len(findings) == 1
assert findings[0]["resolved_value"] == "fr-LU"
assert findings[0]["source"] == "hardcoded"
@pytest.mark.unit
@pytest.mark.dev
class TestCheckLanguageConfig:
"""Tests for _check_language_config."""
def test_no_finding_custom_languages(self):
store = _make_store(storefront_languages=["fr", "en"])
assert _check_language_config(store) == []
def test_finding_default_languages(self):
store = _make_store(storefront_languages=["fr", "de", "en", "lb"])
findings = _check_language_config(store)
assert len(findings) == 1
assert findings[0]["risk"] == "medium"
def test_finding_none_languages(self):
store = _make_store(storefront_languages=None)
findings = _check_language_config(store)
assert len(findings) == 1

View File

@@ -23,7 +23,7 @@ class ModuleConfig(BaseSettings):
# Default currency for pricing
default_currency: str = "EUR"
model_config = {"env_prefix": "HOSTING_"}
model_config = {"env_prefix": "HOSTING_", "env_file": ".env", "extra": "ignore"}
# Export for auto-discovery

View File

@@ -23,7 +23,7 @@ class ModuleConfig(BaseSettings):
# api_timeout: int = 30
# batch_size: int = 100
model_config = {"env_prefix": "INVENTORY_"}
model_config = {"env_prefix": "INVENTORY_", "env_file": ".env", "extra": "ignore"}
# Export for auto-discovery

View File

@@ -45,7 +45,7 @@ class ModuleConfig(BaseSettings):
# QR code settings
qr_code_size: int = 300 # pixels
model_config = {"env_prefix": "LOYALTY_"}
model_config = {"env_prefix": "LOYALTY_", "env_file": ".env", "extra": "ignore"}
# Export for auto-discovery

View File

@@ -127,6 +127,7 @@ loyalty_module = ModuleDefinition(
FrontendType.ADMIN: [
"loyalty-programs", # View all programs
"loyalty-analytics", # Platform-wide stats
"wallet-debug", # Wallet diagnostics (super admin)
],
FrontendType.STORE: [
"terminal", # Loyalty terminal
@@ -162,6 +163,14 @@ loyalty_module = ModuleDefinition(
route="/admin/loyalty/analytics",
order=20,
),
MenuItemDefinition(
id="wallet-debug",
label_key="loyalty.menu.wallet_debug",
icon="beaker",
route="/admin/loyalty/wallet-debug",
order=30,
is_super_admin_only=True,
),
],
),
],

View File

@@ -80,7 +80,8 @@
"statistics": "Statistiken",
"program": "Programm",
"overview": "Übersicht",
"settings": "Einstellungen"
"settings": "Einstellungen",
"wallet_debug": "Wallet Debug"
},
"permissions": {
"view_programs": "Programme anzeigen",

View File

@@ -90,7 +90,8 @@
"statistics": "Statistics",
"program": "Program",
"overview": "Overview",
"settings": "Settings"
"settings": "Settings",
"wallet_debug": "Wallet Debug"
},
"onboarding": {
"create_program": {

View File

@@ -80,7 +80,8 @@
"statistics": "Statistiques",
"program": "Programme",
"overview": "Aperçu",
"settings": "Paramètres"
"settings": "Paramètres",
"wallet_debug": "Wallet Debug"
},
"permissions": {
"view_programs": "Voir les programmes",

View File

@@ -80,7 +80,8 @@
"statistics": "Statistiken",
"program": "Programm",
"overview": "Iwwersiicht",
"settings": "Astellungen"
"settings": "Astellungen",
"wallet_debug": "Wallet Debug"
},
"permissions": {
"view_programs": "Programmer kucken",

View File

@@ -13,7 +13,11 @@ import logging
from fastapi import APIRouter, Depends, Path, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api, require_module_access
from app.api.deps import (
get_current_admin_api,
get_current_super_admin_api,
require_module_access,
)
from app.core.database import get_db
from app.modules.enums import FrontendType
from app.modules.loyalty.schemas import (
@@ -282,3 +286,262 @@ def get_wallet_status(
):
"""Get wallet integration status for the platform."""
return program_service.get_wallet_integration_status(db)
# =============================================================================
# Wallet Debug (super admin only)
# =============================================================================
@router.get("/debug/config") # noqa: API001
def debug_wallet_config(
current_user: User = Depends(get_current_super_admin_api),
db: Session = Depends(get_db),
):
"""Validate Google Wallet configuration (super admin only)."""
from app.modules.loyalty.config import config as loyalty_config
from app.modules.loyalty.services.google_wallet_service import google_wallet_service
result = google_wallet_service.validate_config()
result["origins"] = loyalty_config.google_wallet_origins or []
result["default_logo_url"] = loyalty_config.default_logo_url
# Check Apple Wallet config too
from app.modules.loyalty.services.apple_wallet_service import apple_wallet_service
apple_config = apple_wallet_service.validate_config()
result["apple"] = apple_config
return result
@router.get("/debug/classes") # noqa: API001
def debug_wallet_classes(
current_user: User = Depends(get_current_super_admin_api),
db: Session = Depends(get_db),
):
"""List all programs with their Google Wallet class status (super admin only)."""
from app.modules.loyalty.services.google_wallet_service import google_wallet_service
programs, _ = program_service.list_programs(db, skip=0, limit=1000)
results = []
for program in programs:
entry = {
"program_id": program.id,
"merchant_name": program.merchant.name if program.merchant else None,
"program_name": program.display_name,
"google_class_id": program.google_class_id,
"review_status": "NOT_CREATED",
"class_metadata": None,
}
if program.google_class_id:
status = google_wallet_service.get_class_status(program.google_class_id)
if status:
entry["review_status"] = status.get("review_status", "UNKNOWN")
entry["class_metadata"] = status
else:
entry["review_status"] = "UNKNOWN"
results.append(entry)
return {"programs": results}
@router.post("/debug/classes/{program_id}/create") # noqa: API001
def debug_create_wallet_class(
program_id: int = Path(..., gt=0),
current_user: User = Depends(get_current_super_admin_api),
db: Session = Depends(get_db),
):
"""Manually create a Google Wallet class for a program (super admin only)."""
from app.modules.loyalty.services.google_wallet_service import google_wallet_service
program = program_service.require_program(db, program_id)
try:
class_id = google_wallet_service.create_class(db, program)
return {
"success": True,
"class_id": class_id,
"program_id": program_id,
}
except Exception as exc:
return {
"success": False,
"error": str(exc),
"program_id": program_id,
}
@router.get("/debug/cards/{card_id}") # noqa: API001
def debug_inspect_card(
card_id: int = Path(..., ge=0),
card_number: str | None = Query(None, description="Search by card number instead"),
current_user: User = Depends(get_current_super_admin_api),
db: Session = Depends(get_db),
):
"""Inspect a card's wallet state (super admin only)."""
from app.modules.loyalty.services.card_service import card_service
from app.modules.loyalty.services.google_wallet_service import google_wallet_service
card = None
if card_number:
card = card_service.get_card_by_number(db, card_number)
elif card_id > 0:
card = card_service.get_card(db, card_id)
if not card:
return {"error": "Card not found"}
# Decode JWT preview if present
jwt_info = None
if card.google_object_jwt:
try:
import base64
import json
parts = card.google_object_jwt.split(".")
if len(parts) == 3:
# Decode header and payload (without verification)
payload_b64 = parts[1] + "=" * (4 - len(parts[1]) % 4)
payload = json.loads(base64.urlsafe_b64decode(payload_b64))
jwt_type = "fat" if "payload" in payload and any(
obj.get("classId") or obj.get("id")
for obj in payload.get("payload", {}).get("loyaltyObjects", [])
if isinstance(obj, dict) and ("classId" in obj or "state" in obj)
) else "reference"
jwt_info = {
"present": True,
"type": jwt_type,
"iss": payload.get("iss"),
"aud": payload.get("aud"),
"exp": payload.get("exp"),
"truncated_token": card.google_object_jwt[:80] + "...",
}
except Exception:
jwt_info = {"present": True, "type": "unknown", "decode_error": True}
# Check if object exists in Google
google_object_exists = None
if card.google_object_id and google_wallet_service.is_configured:
try:
http = google_wallet_service._get_http_client()
resp = http.get(
f"https://walletobjects.googleapis.com/walletobjects/v1/loyaltyObject/{card.google_object_id}"
)
google_object_exists = resp.status_code == 200
except Exception:
google_object_exists = None
return {
"card_id": card.id,
"card_number": card.card_number,
"customer_email": card.customer.email if card.customer else None,
"program_name": card.program.display_name if card.program else None,
"program_id": card.program_id,
"is_active": card.is_active,
"google_object_id": card.google_object_id,
"google_object_jwt": jwt_info,
"has_google_wallet": bool(card.google_object_id),
"google_object_exists_in_api": google_object_exists,
"apple_serial_number": card.apple_serial_number,
"has_apple_wallet": bool(card.apple_serial_number),
"created_at": str(card.created_at) if card.created_at else None,
}
@router.post("/debug/cards/{card_id}/generate-url") # noqa: API001
def debug_generate_save_url(
card_id: int = Path(..., gt=0),
current_user: User = Depends(get_current_super_admin_api),
db: Session = Depends(get_db),
):
"""Generate a fresh Google Wallet save URL for a card (super admin only)."""
from app.modules.loyalty.services.card_service import card_service
from app.modules.loyalty.services.google_wallet_service import google_wallet_service
card = card_service.get_card(db, card_id)
if not card:
return {"error": "Card not found"}
try:
url = google_wallet_service.get_save_url(db, card)
# Decode JWT to show preview
jwt_preview = None
if card.google_object_jwt:
try:
import base64
import json
parts = card.google_object_jwt.split(".")
if len(parts) == 3:
payload_b64 = parts[1] + "=" * (4 - len(parts[1]) % 4)
payload = json.loads(base64.urlsafe_b64decode(payload_b64))
# Determine if fat or reference JWT
objects = payload.get("payload", {}).get("loyaltyObjects", [])
is_fat = any(
isinstance(obj, dict) and ("classId" in obj or "state" in obj)
for obj in objects
)
jwt_preview = {
"type": "fat" if is_fat else "reference",
"iss": payload.get("iss"),
"aud": payload.get("aud"),
"exp": payload.get("exp"),
}
except Exception:
jwt_preview = {"type": "unknown"}
return {
"success": True,
"url": url,
"card_id": card_id,
"jwt_preview": jwt_preview,
}
except Exception as exc:
return {
"success": False,
"error": str(exc),
"card_id": card_id,
}
@router.get("/debug/recent-enrollments") # noqa: API001
def debug_recent_enrollments(
current_user: User = Depends(get_current_super_admin_api),
db: Session = Depends(get_db),
):
"""Show the last 20 enrollments with wallet status (super admin only)."""
from app.modules.loyalty.services.card_service import card_service
cards = card_service.get_recent_cards(db, limit=20)
results = []
for card in cards:
has_object = bool(card.google_object_id)
has_jwt = bool(card.google_object_jwt)
if has_object:
status = "wallet_ready"
elif has_jwt:
status = "jwt_only"
else:
status = "no_wallet"
results.append({
"card_id": card.id,
"card_number": card.card_number,
"customer_email": card.customer.email if card.customer else None,
"program_name": card.program.display_name if card.program else None,
"enrolled_at": str(card.created_at) if card.created_at else None,
"google_object_id": card.google_object_id,
"has_google_jwt": has_jwt,
"has_google_wallet": has_object,
"has_apple_wallet": bool(card.apple_serial_number),
"status": status,
})
return {"enrollments": results}

View File

@@ -69,6 +69,19 @@ async def admin_loyalty_analytics(
# ============================================================================
@router.get("/wallet-debug", response_class=HTMLResponse, include_in_schema=False)
async def admin_wallet_debug(
request: Request,
current_user: User = Depends(require_menu_access("wallet-debug", FrontendType.ADMIN)),
db: Session = Depends(get_db),
):
"""Render wallet diagnostics debug page (super admin only)."""
return templates.TemplateResponse(
"loyalty/admin/wallet-debug.html",
get_admin_context(request, db, current_user),
)
@router.get(
"/merchants/{merchant_id}",
response_class=HTMLResponse,

View File

@@ -232,9 +232,9 @@ class StorefrontProgramResponse(ProgramResponse):
model_config = ConfigDict(from_attributes=True)
google_issuer_id: None = Field(None, exclude=True)
google_class_id: None = Field(None, exclude=True)
apple_pass_type_id: None = Field(None, exclude=True)
google_issuer_id: str | None = Field(None, exclude=True)
google_class_id: str | None = Field(None, exclude=True)
apple_pass_type_id: str | None = Field(None, exclude=True)
class ProgramListResponse(BaseModel):

View File

@@ -52,6 +52,19 @@ class CardService:
.first()
)
def get_recent_cards(self, db: Session, limit: int = 20) -> list[LoyaltyCard]:
"""Get the most recently created cards with program and customer loaded."""
return (
db.query(LoyaltyCard)
.options(
joinedload(LoyaltyCard.customer),
joinedload(LoyaltyCard.program),
)
.order_by(LoyaltyCard.created_at.desc())
.limit(limit)
.all()
)
def get_card_for_update(self, db: Session, card_id: int) -> LoyaltyCard | None:
"""Get a loyalty card by ID with a row-level lock (SELECT ... FOR UPDATE).

View File

@@ -250,41 +250,8 @@ class GoogleWalletService:
if not self.is_configured:
raise GoogleWalletNotConfiguredException()
issuer_id = config.google_issuer_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 = {
"id": class_id,
"issuerId": issuer_id,
"issuerName": issuer_name,
"reviewStatus": "DRAFT",
"programName": program.display_name,
"hexBackgroundColor": program.card_color or "#4285F4",
"localizedProgramName": {
"defaultValue": {
"language": "en",
"value": program.display_name,
},
},
}
# 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 = config.default_logo_url
class_data["programLogo"] = {
"sourceUri": {"uri": logo_url},
}
# Add hero image if configured
if program.hero_image_url:
class_data["heroImage"] = {
"sourceUri": {"uri": program.hero_image_url},
}
class_id = f"{config.google_issuer_id}.loyalty_program_{program.id}"
class_data = self._build_class_data(program, class_id)
try:
http = self._get_http_client()
@@ -445,15 +412,50 @@ class GoogleWalletService:
except (requests.RequestException, ValueError, AttributeError) as exc:
logger.error("Failed to update Google Wallet object: %s", exc)
def _build_class_data(
self, program: LoyaltyProgram, class_id: str,
*, review_status: str = "UNDER_REVIEW",
) -> dict[str, Any]:
"""Build the LoyaltyClass data structure."""
issuer_id = config.google_issuer_id
issuer_name = program.merchant.name if program.merchant else program.display_name
class_data: dict[str, Any] = {
"id": class_id,
"issuerId": issuer_id,
"issuerName": issuer_name,
"reviewStatus": review_status,
"programName": program.display_name,
"hexBackgroundColor": program.card_color or "#4285F4",
"localizedProgramName": {
"defaultValue": {
"language": "en",
"value": program.display_name,
},
},
}
logo_url = program.logo_url or config.default_logo_url
class_data["programLogo"] = {
"sourceUri": {"uri": logo_url},
}
if program.hero_image_url:
class_data["heroImage"] = {
"sourceUri": {"uri": program.hero_image_url},
}
return class_data
def _build_object_data(
self, card: LoyaltyCard, object_id: str
self, card: LoyaltyCard, object_id: str, class_id: str | None = None
) -> dict[str, Any]:
"""Build the LoyaltyObject data structure."""
program = card.program
object_data: dict[str, Any] = {
"id": object_id,
"classId": program.google_class_id,
"classId": class_id or program.google_class_id,
"state": "ACTIVE" if card.is_active else "INACTIVE",
"accountId": card.card_number,
"accountName": card.card_number,
@@ -537,9 +539,14 @@ class GoogleWalletService:
"loyaltyObjects": [{"id": card.google_object_id}],
}
else:
# Object not created — embed full object data in JWT
object_data = self._build_object_data(card, object_id)
# Object not created — embed full class + object data in JWT
# ("fat JWT"). Both are required for Google to render and save.
program = card.program
class_id = program.google_class_id or f"{issuer_id}.loyalty_program_{program.id}"
class_data = self._build_class_data(program, class_id)
object_data = self._build_object_data(card, object_id, class_id=class_id)
payload = {
"loyaltyClasses": [class_data],
"loyaltyObjects": [object_data],
}

View File

@@ -0,0 +1,905 @@
{# app/modules/loyalty/templates/loyalty/admin/wallet-debug.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/tables.html' import table_wrapper, table_header %}
{% block title %}Wallet Debug{% endblock %}
{% block alpine_data %}walletDebug(){% endblock %}
{% block content %}
{{ page_header('Wallet Diagnostics', back_url='/admin/loyalty/programs', back_label='Back to Programs') }}
<div class="flex gap-6">
<!-- ═══════════════════════════════════════════════════════════════════ -->
<!-- Left sidebar — Diagnostic Tools explorer -->
<!-- ═══════════════════════════════════════════════════════════════════ -->
<div class="w-72 flex-shrink-0">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<div class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider mb-3 flex items-center gap-1.5">
<span x-html="$icon('beaker', 'w-4 h-4')"></span>
Wallet Tools
</div>
<template x-for="group in toolGroups" :key="group.category">
<div class="mb-1">
<button @click="toggleCategory(group.category)"
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">
<span x-text="group.category"></span>
<span class="text-[10px] font-mono leading-none" x-text="isCategoryExpanded(group.category) ? '' : '+'"></span>
</button>
<ul x-show="isCategoryExpanded(group.category)" x-collapse class="space-y-0.5 mt-0.5">
<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>
<!-- ═══════════════════════════════════════════════════════════════════ -->
<!-- Main area — tool content panels -->
<!-- ═══════════════════════════════════════════════════════════════════ -->
<div class="flex-1 min-w-0">
<!-- ─────────────────────────────────────────────────────────── -->
<!-- Tool: Config Validation -->
<!-- ─────────────────────────────────────────────────────────── -->
<div x-show="activeTool === 'config-check'" x-cloak>
<div class="mb-6">
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Config Validation</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Validates Google Wallet configuration: service account, credentials, issuer ID, origins, and logo URL reachability.
</p>
</div>
<div class="mb-4 flex items-center gap-3">
<button @click="loadConfig()" :disabled="configLoading"
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="!configLoading">Run Validation</span>
<span x-show="configLoading">Checking...</span>
</button>
<template x-if="configData">
<button @click="copyConfigResults()"
class="px-3 py-2 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 font-medium">
Copy Results
</button>
</template>
</div>
<template x-if="configData">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs p-6 space-y-3">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase mb-4">Google Wallet</h3>
<!-- Checklist items -->
<div class="space-y-2 text-sm">
<div class="flex items-center gap-2">
<span x-text="configData.configured ? '✅' : '❌'"></span>
<span class="text-gray-700 dark:text-gray-300">Configured:</span>
<span class="font-mono text-gray-500 dark:text-gray-400" x-text="configData.configured ? 'Yes' : 'No'"></span>
</div>
<div class="flex items-center gap-2">
<span x-text="configData.service_account_path ? '✅' : '❌'"></span>
<span class="text-gray-700 dark:text-gray-300">Service Account File:</span>
<span class="font-mono text-xs text-gray-500 dark:text-gray-400" x-text="configData.service_account_path || 'Not set'"></span>
</div>
<div class="flex items-center gap-2">
<span x-text="configData.credentials_valid ? '✅' : '❌'"></span>
<span class="text-gray-700 dark:text-gray-300">Credentials Valid:</span>
<span class="font-mono text-gray-500 dark:text-gray-400" x-text="configData.credentials_valid ? 'Yes' : 'No'"></span>
</div>
<div class="flex items-center gap-2">
<span x-text="configData.issuer_id ? '✅' : '❌'"></span>
<span class="text-gray-700 dark:text-gray-300">Issuer ID:</span>
<span class="font-mono text-gray-500 dark:text-gray-400" x-text="configData.issuer_id || 'Not set'"></span>
</div>
<div class="flex items-start gap-2">
<span x-text="configData.origins && configData.origins.length > 0 ? '✅' : '⚠️'"></span>
<span class="text-gray-700 dark:text-gray-300">Wallet Origins:</span>
<div>
<template x-if="configData.origins && configData.origins.length > 0">
<ul class="font-mono text-xs text-gray-500 dark:text-gray-400">
<template x-for="origin in configData.origins" :key="origin">
<li x-text="origin"></li>
</template>
</ul>
</template>
<template x-if="!configData.origins || configData.origins.length === 0">
<span class="text-xs text-amber-600 dark:text-amber-400">Empty (wallet button may not work)</span>
</template>
</div>
</div>
<div class="flex items-center gap-2">
<span x-text="configData.default_logo_url ? '✅' : '⚠️'"></span>
<span class="text-gray-700 dark:text-gray-300">Default Logo URL:</span>
<span class="font-mono text-xs text-gray-500 dark:text-gray-400 truncate max-w-md" x-text="configData.default_logo_url || 'Not set'"></span>
</div>
<template x-if="configData.service_account_email">
<div class="flex items-center gap-2">
<span>📧</span>
<span class="text-gray-700 dark:text-gray-300">Service Account Email:</span>
<span class="font-mono text-xs text-gray-500 dark:text-gray-400" x-text="configData.service_account_email"></span>
</div>
</template>
<template x-if="configData.project_id">
<div class="flex items-center gap-2">
<span>📂</span>
<span class="text-gray-700 dark:text-gray-300">Project ID:</span>
<span class="font-mono text-xs text-gray-500 dark:text-gray-400" x-text="configData.project_id"></span>
</div>
</template>
</div>
<!-- Errors -->
<template x-if="configData.errors && configData.errors.length > 0">
<div class="mt-4 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
<h4 class="text-sm font-semibold text-red-700 dark:text-red-400 mb-2">Errors</h4>
<ul class="text-sm text-red-600 dark:text-red-400 space-y-1">
<template x-for="err in configData.errors" :key="err">
<li class="flex items-start gap-1">
<span></span>
<span x-text="err"></span>
</li>
</template>
</ul>
</div>
</template>
<!-- Warnings -->
<template x-if="configData.warnings && configData.warnings.length > 0">
<div class="mt-4 p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg">
<h4 class="text-sm font-semibold text-amber-700 dark:text-amber-400 mb-2">Warnings</h4>
<ul class="text-sm text-amber-600 dark:text-amber-400 space-y-1">
<template x-for="w in configData.warnings" :key="w">
<li class="flex items-start gap-1">
<span>⚠️</span>
<span x-text="w"></span>
</li>
</template>
</ul>
</div>
</template>
<!-- Apple Wallet section -->
<div class="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase mb-4">Apple Wallet</h3>
<div class="space-y-2 text-sm">
<div class="flex items-center gap-2">
<span x-text="configData.apple && configData.apple.configured ? '✅' : '❌'"></span>
<span class="text-gray-700 dark:text-gray-300">Configured:</span>
<span class="font-mono text-gray-500 dark:text-gray-400" x-text="configData.apple && configData.apple.configured ? 'Yes' : 'No'"></span>
</div>
<template x-if="configData.apple && configData.apple.pass_type_id">
<div class="flex items-center gap-2">
<span>🎫</span>
<span class="text-gray-700 dark:text-gray-300">Pass Type ID:</span>
<span class="font-mono text-xs text-gray-500 dark:text-gray-400" x-text="configData.apple.pass_type_id"></span>
</div>
</template>
<template x-if="configData.apple && configData.apple.team_id">
<div class="flex items-center gap-2">
<span>🏢</span>
<span class="text-gray-700 dark:text-gray-300">Team ID:</span>
<span class="font-mono text-xs text-gray-500 dark:text-gray-400" x-text="configData.apple.team_id"></span>
</div>
</template>
<template x-if="configData.apple && !configData.apple.configured">
<div class="p-3 bg-gray-50 dark:bg-gray-900 rounded text-xs text-gray-500 dark:text-gray-400">
Apple Wallet is not configured. Required env vars: LOYALTY_APPLE_PASS_TYPE_ID, LOYALTY_APPLE_TEAM_ID, LOYALTY_APPLE_WWDR_CERT_PATH, LOYALTY_APPLE_SIGNER_CERT_PATH, LOYALTY_APPLE_SIGNER_KEY_PATH
</div>
</template>
</div>
</div>
</div>
</template>
<template x-if="configError">
<div class="mt-4 p-4 bg-red-50 dark:bg-red-900/20 rounded-lg text-sm text-red-600 dark:text-red-400" x-text="configError"></div>
</template>
</div>
<!-- ─────────────────────────────────────────────────────────── -->
<!-- Tool: Class Status -->
<!-- ─────────────────────────────────────────────────────────── -->
<div x-show="activeTool === 'class-status'" x-cloak>
<div class="mb-6">
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Class Status</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Shows Google Wallet class review status for each loyalty program. Classes must be APPROVED for wallet buttons to work.
</p>
</div>
<div class="mb-4 flex items-center gap-3">
<button @click="loadClasses()" :disabled="classesLoading"
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="!classesLoading">Check Status</span>
<span x-show="classesLoading">Loading...</span>
</button>
<template x-if="classesData">
<button @click="copyClassesResults()"
class="px-3 py-2 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 font-medium">
Copy Results
</button>
</template>
</div>
<template x-if="classesData">
{% call table_wrapper() %}
{{ table_header(['Program', 'Merchant', 'Class ID', 'Review Status', 'Actions']) }}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="cls in classesData.programs" :key="cls.program_id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-4 py-3 font-medium text-gray-900 dark:text-white" x-text="cls.program_name"></td>
<td class="px-4 py-3" x-text="cls.merchant_name || '—'"></td>
<td class="px-4 py-3 font-mono text-xs" x-text="cls.google_class_id || '—'"></td>
<td class="px-4 py-3">
<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': cls.review_status === 'APPROVED',
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300': cls.review_status === 'DRAFT' || cls.review_status === 'UNDER_REVIEW',
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300': cls.review_status === 'REJECTED',
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300': cls.review_status === 'NOT_CREATED' || cls.review_status === 'UNKNOWN'
}" x-text="cls.review_status"></span>
</td>
<td class="px-4 py-3">
<template x-if="cls.review_status === 'NOT_CREATED'">
<button @click="createClass(cls.program_id)" :disabled="classCreating === cls.program_id"
class="px-3 py-1 text-xs bg-indigo-600 text-white rounded hover:bg-indigo-700 disabled:opacity-50">
<span x-show="classCreating !== cls.program_id">Create Class</span>
<span x-show="classCreating === cls.program_id">Creating...</span>
</button>
</template>
</td>
</tr>
</template>
</tbody>
{% endcall %}
</template>
<template x-if="classesError">
<div class="mt-4 p-4 bg-red-50 dark:bg-red-900/20 rounded-lg text-sm text-red-600 dark:text-red-400" x-text="classesError"></div>
</template>
</div>
<!-- ─────────────────────────────────────────────────────────── -->
<!-- Tool: Card Inspector -->
<!-- ─────────────────────────────────────────────────────────── -->
<div x-show="activeTool === 'card-inspector'" x-cloak>
<div class="mb-6">
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Card Inspector</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Inspect a loyalty card's wallet integration state, including Google Wallet object status and JWT details.
</p>
</div>
<div class="mb-6 p-4 bg-white dark:bg-gray-800 rounded-lg shadow-xs">
<div class="flex gap-3 items-end flex-wrap">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Card Number or ID</label>
<input x-model="cardSearch" type="text" placeholder="XXXX-XXXX-XXXX or card ID"
@keydown.enter="inspectCard()"
class="px-3 py-2 border rounded-lg text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white w-64">
</div>
<button @click="inspectCard()" :disabled="cardLoading"
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="!cardLoading">Inspect</span>
<span x-show="cardLoading">Loading...</span>
</button>
<template x-if="cardData && !cardData.error">
<button @click="copyCardResults()"
class="px-3 py-2 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 font-medium">
Copy Results
</button>
</template>
</div>
</div>
<template x-if="cardData && !cardData.error">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs p-6 space-y-3">
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-gray-500 dark:text-gray-400">Card Number:</span>
<span class="ml-2 font-mono text-gray-900 dark:text-white" x-text="cardData.card_number"></span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Card ID:</span>
<span class="ml-2 font-mono text-gray-900 dark:text-white" x-text="cardData.card_id"></span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Customer:</span>
<span class="ml-2 text-gray-900 dark:text-white" x-text="cardData.customer_email || '—'"></span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Program:</span>
<span class="ml-2 text-gray-900 dark:text-white" x-text="cardData.program_name || '—'"></span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Active:</span>
<span class="ml-2" x-text="cardData.is_active ? '✅ Yes' : '❌ No'"></span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Created:</span>
<span class="ml-2 text-xs text-gray-900 dark:text-white" x-text="cardData.created_at || '—'"></span>
</div>
</div>
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Google Wallet</h4>
<div class="space-y-2 text-sm">
<div class="flex items-center gap-2">
<span x-text="cardData.google_object_id ? '✅' : '❌'"></span>
<span class="text-gray-700 dark:text-gray-300">Object ID:</span>
<span class="font-mono text-xs text-gray-500 dark:text-gray-400" x-text="cardData.google_object_id || 'Not created'"></span>
</div>
<div class="flex items-center gap-2">
<span x-text="cardData.google_object_jwt && cardData.google_object_jwt.present ? '✅' : '❌'"></span>
<span class="text-gray-700 dark:text-gray-300">JWT:</span>
<template x-if="cardData.google_object_jwt && cardData.google_object_jwt.present">
<span class="text-xs">
<span class="px-2 py-0.5 rounded-full font-medium"
:class="cardData.google_object_jwt.type === 'reference'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'
: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-300'"
x-text="cardData.google_object_jwt.type"></span>
<span class="ml-2 font-mono text-gray-400" x-text="'iss: ' + (cardData.google_object_jwt.iss || '?')"></span>
</span>
</template>
<template x-if="!cardData.google_object_jwt || !cardData.google_object_jwt.present">
<span class="text-xs text-gray-500 dark:text-gray-400">Not present</span>
</template>
</div>
<div class="flex items-center gap-2">
<span x-text="cardData.google_object_exists_in_api === true ? '✅' : (cardData.google_object_exists_in_api === false ? '❌' : '⚪')"></span>
<span class="text-gray-700 dark:text-gray-300">Live API Check:</span>
<span class="text-xs text-gray-500 dark:text-gray-400"
x-text="cardData.google_object_exists_in_api === true ? 'Object exists in Google'
: cardData.google_object_exists_in_api === false ? 'Object NOT found in Google'
: 'Not checked'"></span>
</div>
</div>
</div>
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Apple Wallet</h4>
<div class="flex items-center gap-2 text-sm">
<span x-text="cardData.has_apple_wallet ? '✅' : '❌'"></span>
<span class="text-gray-700 dark:text-gray-300">Serial Number:</span>
<span class="font-mono text-xs text-gray-500 dark:text-gray-400" x-text="cardData.apple_serial_number || 'Not created'"></span>
</div>
</div>
<!-- Actions -->
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700 flex gap-3">
<button @click="generateUrlFromInspector(cardData.card_id)"
class="px-3 py-1.5 text-xs bg-indigo-600 text-white rounded hover:bg-indigo-700">
Generate Save URL
</button>
</div>
</div>
</template>
<template x-if="cardData && cardData.error">
<div class="mt-4 p-4 bg-red-50 dark:bg-red-900/20 rounded-lg text-sm text-red-600 dark:text-red-400" x-text="cardData.error"></div>
</template>
<template x-if="cardError">
<div class="mt-4 p-4 bg-red-50 dark:bg-red-900/20 rounded-lg text-sm text-red-600 dark:text-red-400" x-text="cardError"></div>
</template>
</div>
<!-- ─────────────────────────────────────────────────────────── -->
<!-- Tool: Save URL Tester -->
<!-- ─────────────────────────────────────────────────────────── -->
<div x-show="activeTool === 'url-tester'" x-cloak>
<div class="mb-6">
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Save URL Tester</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Generate a fresh Google Wallet save URL for a card and inspect the JWT.
</p>
</div>
<div class="mb-6 p-4 bg-white dark:bg-gray-800 rounded-lg shadow-xs">
<div class="flex gap-3 items-end flex-wrap">
<div>
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Card ID</label>
<input x-model="urlCardId" type="number" placeholder="Card ID"
@keydown.enter="generateUrl()"
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="generateUrl()" :disabled="urlLoading"
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="!urlLoading">Generate URL</span>
<span x-show="urlLoading">Generating...</span>
</button>
<template x-if="urlData && urlData.success">
<button @click="copyUrlResults()"
class="px-3 py-2 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 font-medium">
Copy Results
</button>
</template>
</div>
</div>
<template x-if="urlData && urlData.success">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs p-6 space-y-4">
<div>
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Generated URL</h4>
<div class="flex items-center gap-2">
<a :href="urlData.url" target="_blank" rel="noopener"
class="text-sm text-indigo-600 dark:text-indigo-400 hover:underline font-mono break-all" x-text="urlData.url.substring(0, 100) + '...'"></a>
<button @click="navigator.clipboard.writeText(urlData.url)"
class="flex-shrink-0 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>
<a :href="urlData.url" target="_blank" rel="noopener"
class="flex-shrink-0 px-2 py-1 text-xs bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300 rounded hover:bg-green-200 dark:hover:bg-green-800">
Open
</a>
</div>
</div>
<template x-if="urlData.jwt_preview">
<div>
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">JWT Details</h4>
<div class="space-y-1 text-sm">
<div class="flex items-center gap-2">
<span class="text-gray-500 dark:text-gray-400">Type:</span>
<span class="px-2 py-0.5 rounded-full text-xs font-medium"
:class="urlData.jwt_preview.type === 'reference'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'
: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-300'"
x-text="urlData.jwt_preview.type"></span>
<span class="text-xs text-gray-400" x-text="urlData.jwt_preview.type === 'fat' ? '(object data embedded in JWT — class may not be approved)' : '(references existing object by ID)'"></span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Issuer:</span>
<span class="ml-2 font-mono text-xs text-gray-900 dark:text-white" x-text="urlData.jwt_preview.iss || '—'"></span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Audience:</span>
<span class="ml-2 font-mono text-xs text-gray-900 dark:text-white" x-text="urlData.jwt_preview.aud || '—'"></span>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">Expires:</span>
<span class="ml-2 font-mono text-xs text-gray-900 dark:text-white" x-text="urlData.jwt_preview.exp ? new Date(urlData.jwt_preview.exp * 1000).toISOString() : '—'"></span>
</div>
</div>
</div>
</template>
</div>
</template>
<template x-if="urlData && !urlData.success">
<div class="mt-4 p-4 bg-red-50 dark:bg-red-900/20 rounded-lg text-sm text-red-600 dark:text-red-400" x-text="urlData.error"></div>
</template>
<template x-if="urlError">
<div class="mt-4 p-4 bg-red-50 dark:bg-red-900/20 rounded-lg text-sm text-red-600 dark:text-red-400" x-text="urlError"></div>
</template>
</div>
<!-- ─────────────────────────────────────────────────────────── -->
<!-- Tool: Recent Enrollments -->
<!-- ─────────────────────────────────────────────────────────── -->
<div x-show="activeTool === 'recent-enrollments'" x-cloak>
<div class="mb-6">
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Recent Enrollments</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Shows the last 20 enrollments with their wallet integration status.
</p>
</div>
<div class="mb-4 flex items-center gap-3">
<button @click="loadEnrollments()" :disabled="enrollmentsLoading"
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="!enrollmentsLoading">Load Enrollments</span>
<span x-show="enrollmentsLoading">Loading...</span>
</button>
<template x-if="enrollmentsData">
<button @click="copyEnrollmentsResults()"
class="px-3 py-2 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 font-medium">
Copy Results
</button>
</template>
</div>
<template x-if="enrollmentsData">
{% call table_wrapper() %}
{{ table_header(['Card #', 'Customer', 'Program', 'Enrolled At', 'Google Object', 'Google JWT', 'Status', 'Actions']) }}
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<template x-for="enr in enrollmentsData.enrollments" :key="enr.card_id">
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-4 py-3 font-mono text-xs" x-text="enr.card_number"></td>
<td class="px-4 py-3 text-xs" x-text="enr.customer_email || '—'"></td>
<td class="px-4 py-3 text-xs" x-text="enr.program_name || '—'"></td>
<td class="px-4 py-3 text-xs" x-text="enr.enrolled_at ? enr.enrolled_at.substring(0, 19) : '—'"></td>
<td class="px-4 py-3">
<span x-text="enr.has_google_wallet ? '✅' : '❌'" class="text-xs"></span>
</td>
<td class="px-4 py-3">
<span x-text="enr.has_google_jwt ? '✅' : '❌'" class="text-xs"></span>
</td>
<td class="px-4 py-3">
<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': enr.status === 'wallet_ready',
'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-300': enr.status === 'jwt_only',
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300': enr.status === 'no_wallet'
}"
x-text="enr.status === 'wallet_ready' ? '✅ Ready' : enr.status === 'jwt_only' ? '⚠️ JWT only' : '❌ None'"></span>
</td>
<td class="px-4 py-3">
<button @click="cardSearch = enr.card_number; selectTool('card-inspector'); inspectCard()"
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">
Inspect
</button>
</td>
</tr>
</template>
</tbody>
{% endcall %}
<template x-if="enrollmentsData.enrollments.length === 0">
<div class="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">
No enrollments found.
</div>
</template>
</template>
<template x-if="enrollmentsError">
<div class="mt-4 p-4 bg-red-50 dark:bg-red-900/20 rounded-lg text-sm text-red-600 dark:text-red-400" x-text="enrollmentsError"></div>
</template>
</div>
<!-- ─────────────────────────────────────────────────────────── -->
<!-- Tool: Apple Status -->
<!-- ─────────────────────────────────────────────────────────── -->
<div x-show="activeTool === 'apple-status'" x-cloak>
<div class="mb-6">
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Apple Wallet Status</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Apple Wallet configuration status and pass statistics.
</p>
</div>
<div class="mb-4">
<button @click="loadConfig()" :disabled="configLoading"
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="!configLoading">Check Config</span>
<span x-show="configLoading">Loading...</span>
</button>
</div>
<template x-if="configData && configData.apple">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs p-6">
<div class="space-y-3 text-sm">
<div class="flex items-center gap-2">
<span x-text="configData.apple.configured ? '✅' : '❌'"></span>
<span class="text-gray-700 dark:text-gray-300 font-medium">Configured:</span>
<span class="text-gray-500 dark:text-gray-400" x-text="configData.apple.configured ? 'Yes' : 'No'"></span>
</div>
<template x-if="configData.apple.configured">
<div class="space-y-2">
<div class="flex items-center gap-2">
<span>🎫</span>
<span class="text-gray-700 dark:text-gray-300">Pass Type ID:</span>
<span class="font-mono text-xs text-gray-500 dark:text-gray-400" x-text="configData.apple.pass_type_id"></span>
</div>
<div class="flex items-center gap-2">
<span>🏢</span>
<span class="text-gray-700 dark:text-gray-300">Team ID:</span>
<span class="font-mono text-xs text-gray-500 dark:text-gray-400" x-text="configData.apple.team_id"></span>
</div>
<div class="flex items-center gap-2">
<span x-text="configData.apple.credentials_valid ? '✅' : '❌'"></span>
<span class="text-gray-700 dark:text-gray-300">Credentials Valid:</span>
<span class="text-gray-500 dark:text-gray-400" x-text="configData.apple.credentials_valid ? 'Yes' : 'No'"></span>
</div>
</div>
</template>
<template x-if="!configData.apple.configured">
<div class="mt-4 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">Apple Wallet is not configured. Set these environment variables:</p>
<ul class="font-mono text-xs text-gray-500 dark:text-gray-400 space-y-1">
<li>LOYALTY_APPLE_PASS_TYPE_ID</li>
<li>LOYALTY_APPLE_TEAM_ID</li>
<li>LOYALTY_APPLE_WWDR_CERT_PATH</li>
<li>LOYALTY_APPLE_SIGNER_CERT_PATH</li>
<li>LOYALTY_APPLE_SIGNER_KEY_PATH</li>
</ul>
</div>
</template>
<!-- Apple errors -->
<template x-if="configData.apple.errors && configData.apple.errors.length > 0">
<div class="mt-4 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
<h4 class="text-sm font-semibold text-red-700 dark:text-red-400 mb-2">Errors</h4>
<ul class="text-sm text-red-600 dark:text-red-400 space-y-1">
<template x-for="err in configData.apple.errors" :key="err">
<li x-text="err"></li>
</template>
</ul>
</div>
</template>
</div>
</div>
</template>
<template x-if="!configData">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xs p-8 text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">Click "Check Config" to load Apple Wallet status.</p>
</div>
</template>
</div>
</div>
</div>
<script>
function walletDebug() {
return {
// Inherit base layout functionality
...data(),
// Page identifier
currentPage: 'wallet-debug',
// ── Sidebar / tool navigation ──
activeTool: 'config-check',
expandedCategories: ['Configuration', 'Google Wallet', 'Enrollment', 'Apple Wallet'],
toolGroups: [
{
category: 'Configuration',
items: [
{ id: 'config-check', label: 'Config Validation', icon: 'cog' },
],
},
{
category: 'Google Wallet',
items: [
{ id: 'class-status', label: 'Class Status', icon: 'identification' },
{ id: 'card-inspector', label: 'Card Inspector', icon: 'search' },
{ id: 'url-tester', label: 'Save URL Tester', icon: 'link' },
],
},
{
category: 'Enrollment',
items: [
{ id: 'recent-enrollments', label: 'Recent Enrollments', icon: 'users' },
],
},
{
category: 'Apple Wallet',
items: [
{ id: 'apple-status', label: 'Status', icon: 'phone' },
],
},
],
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;
},
// ── Config Validation ──
configData: null,
configLoading: false,
configError: '',
async loadConfig() {
this.configLoading = true;
this.configError = '';
try {
this.configData = await apiClient.get('/admin/loyalty/debug/config');
} catch (e) {
this.configError = e.message || 'Failed to load config';
}
this.configLoading = false;
},
// ── Class Status ──
classesData: null,
classesLoading: false,
classesError: '',
classCreating: null,
async loadClasses() {
this.classesLoading = true;
this.classesError = '';
try {
this.classesData = await apiClient.get('/admin/loyalty/debug/classes');
} catch (e) {
this.classesError = e.message || 'Failed to load classes';
}
this.classesLoading = false;
},
async createClass(programId) {
this.classCreating = programId;
try {
const result = await apiClient.post(`/admin/loyalty/debug/classes/${programId}/create`);
if (result.success) {
await this.loadClasses();
} else {
this.classesError = result.error || 'Failed to create class';
}
} catch (e) {
this.classesError = e.message || 'Failed to create class';
}
this.classCreating = null;
},
// ── Card Inspector ──
cardSearch: '',
cardData: null,
cardLoading: false,
cardError: '',
async inspectCard() {
if (!this.cardSearch.trim()) return;
this.cardLoading = true;
this.cardError = '';
this.cardData = null;
try {
const val = this.cardSearch.trim();
// If it looks like a number (no dashes), use card_id; otherwise card_number
const isNumericId = /^\d+$/.test(val);
let url;
if (isNumericId) {
url = `/admin/loyalty/debug/cards/${val}`;
} else {
url = `/admin/loyalty/debug/cards/0?card_number=${encodeURIComponent(val)}`;
}
this.cardData = await apiClient.get(url);
} catch (e) {
this.cardError = e.message || 'Failed to inspect card';
}
this.cardLoading = false;
},
generateUrlFromInspector(cardId) {
this.urlCardId = cardId;
this.selectTool('url-tester');
this.generateUrl();
},
// ── Save URL Tester ──
urlCardId: '',
urlData: null,
urlLoading: false,
urlError: '',
async generateUrl() {
if (!this.urlCardId) return;
this.urlLoading = true;
this.urlError = '';
this.urlData = null;
try {
this.urlData = await apiClient.post(`/admin/loyalty/debug/cards/${this.urlCardId}/generate-url`);
} catch (e) {
this.urlError = e.message || 'Failed to generate URL';
}
this.urlLoading = false;
},
// ── Recent Enrollments ──
enrollmentsData: null,
enrollmentsLoading: false,
enrollmentsError: '',
async loadEnrollments() {
this.enrollmentsLoading = true;
this.enrollmentsError = '';
try {
this.enrollmentsData = await apiClient.get('/admin/loyalty/debug/recent-enrollments');
} catch (e) {
this.enrollmentsError = e.message || 'Failed to load enrollments';
}
this.enrollmentsLoading = false;
},
// ── Copy helpers ──
copyConfigResults() {
if (!this.configData) return;
const d = this.configData;
let text = '=== Wallet Config Validation ===\n\n';
text += '--- Google Wallet ---\n';
text += `Configured: ${d.configured ? 'Yes' : 'No'}\n`;
text += `Service Account File: ${d.service_account_path || 'Not set'}\n`;
text += `Credentials Valid: ${d.credentials_valid ? 'Yes' : 'No'}\n`;
text += `Issuer ID: ${d.issuer_id || 'Not set'}\n`;
text += `Wallet Origins: ${d.origins && d.origins.length > 0 ? d.origins.join(', ') : 'Empty'}\n`;
text += `Default Logo URL: ${d.default_logo_url || 'Not set'}\n`;
if (d.service_account_email) text += `Service Account Email: ${d.service_account_email}\n`;
if (d.project_id) text += `Project ID: ${d.project_id}\n`;
if (d.errors && d.errors.length > 0) text += `\nErrors:\n${d.errors.map(e => ' - ' + e).join('\n')}\n`;
if (d.warnings && d.warnings.length > 0) text += `\nWarnings:\n${d.warnings.map(w => ' - ' + w).join('\n')}\n`;
if (d.apple) {
text += '\n--- Apple Wallet ---\n';
text += `Configured: ${d.apple.configured ? 'Yes' : 'No'}\n`;
if (d.apple.pass_type_id) text += `Pass Type ID: ${d.apple.pass_type_id}\n`;
if (d.apple.team_id) text += `Team ID: ${d.apple.team_id}\n`;
if (d.apple.credentials_valid !== undefined) text += `Credentials Valid: ${d.apple.credentials_valid ? 'Yes' : 'No'}\n`;
if (d.apple.errors && d.apple.errors.length > 0) text += `Errors:\n${d.apple.errors.map(e => ' - ' + e).join('\n')}\n`;
}
navigator.clipboard.writeText(text);
},
copyClassesResults() {
if (!this.classesData) return;
let text = '=== Wallet Class Status ===\n\n';
text += 'Program | Merchant | Class ID | Review Status\n';
text += '--------|----------|----------|-------------\n';
for (const cls of this.classesData.programs) {
text += `${cls.program_name} | ${cls.merchant_name || '—'} | ${cls.google_class_id || '—'} | ${cls.review_status}\n`;
}
navigator.clipboard.writeText(text);
},
copyCardResults() {
if (!this.cardData || this.cardData.error) return;
const d = this.cardData;
let text = '=== Card Inspector ===\n\n';
text += `Card Number: ${d.card_number}\n`;
text += `Card ID: ${d.card_id}\n`;
text += `Customer: ${d.customer_email || '—'}\n`;
text += `Program: ${d.program_name || '—'}\n`;
text += `Active: ${d.is_active ? 'Yes' : 'No'}\n`;
text += `Created: ${d.created_at || '—'}\n`;
text += '\n--- Google Wallet ---\n';
text += `Object ID: ${d.google_object_id || 'Not created'}\n`;
if (d.google_object_jwt && d.google_object_jwt.present) {
text += `JWT: Present (${d.google_object_jwt.type})\n`;
text += ` Issuer: ${d.google_object_jwt.iss || '?'}\n`;
if (d.google_object_jwt.exp) text += ` Expires: ${new Date(d.google_object_jwt.exp * 1000).toISOString()}\n`;
} else {
text += `JWT: Not present\n`;
}
text += `Live API Check: ${d.google_object_exists_in_api === true ? 'Object exists' : d.google_object_exists_in_api === false ? 'NOT found' : 'Not checked'}\n`;
text += '\n--- Apple Wallet ---\n';
text += `Serial Number: ${d.apple_serial_number || 'Not created'}\n`;
navigator.clipboard.writeText(text);
},
copyUrlResults() {
if (!this.urlData || !this.urlData.success) return;
let text = '=== Save URL ===\n\n';
text += `URL: ${this.urlData.url}\n`;
text += `Card ID: ${this.urlData.card_id}\n`;
if (this.urlData.jwt_preview) {
const j = this.urlData.jwt_preview;
text += `\nJWT Type: ${j.type}\n`;
text += `Issuer: ${j.iss || '—'}\n`;
text += `Audience: ${j.aud || '—'}\n`;
if (j.exp) text += `Expires: ${new Date(j.exp * 1000).toISOString()}\n`;
}
navigator.clipboard.writeText(text);
},
copyEnrollmentsResults() {
if (!this.enrollmentsData) return;
let text = '=== Recent Enrollments ===\n\n';
text += 'Card # | Customer | Program | Enrolled At | Google Obj | JWT | Status\n';
text += '-------|----------|---------|-------------|------------|-----|-------\n';
for (const e of this.enrollmentsData.enrollments) {
text += `${e.card_number} | ${e.customer_email || '—'} | ${e.program_name || '—'} | ${e.enrolled_at ? e.enrolled_at.substring(0, 19) : '—'} | ${e.has_google_wallet ? 'Yes' : 'No'} | ${e.has_google_jwt ? 'Yes' : 'No'} | ${e.status}\n`;
}
navigator.clipboard.writeText(text);
},
};
}
</script>
{% endblock %}

View File

@@ -23,7 +23,7 @@ class ModuleConfig(BaseSettings):
# api_timeout: int = 30
# batch_size: int = 100
model_config = {"env_prefix": "MARKETPLACE_"}
model_config = {"env_prefix": "MARKETPLACE_", "env_file": ".env", "extra": "ignore"}
# Export for auto-discovery

View File

@@ -23,7 +23,7 @@ class ModuleConfig(BaseSettings):
# api_timeout: int = 30
# batch_size: int = 100
model_config = {"env_prefix": "MESSAGING_"}
model_config = {"env_prefix": "MESSAGING_", "env_file": ".env", "extra": "ignore"}
# Export for auto-discovery

View File

@@ -23,7 +23,7 @@ class ModuleConfig(BaseSettings):
# api_timeout: int = 30
# batch_size: int = 100
model_config = {"env_prefix": "MONITORING_"}
model_config = {"env_prefix": "MONITORING_", "env_file": ".env", "extra": "ignore"}
# Export for auto-discovery

View File

@@ -23,7 +23,7 @@ class ModuleConfig(BaseSettings):
# api_timeout: int = 30
# batch_size: int = 100
model_config = {"env_prefix": "ORDERS_"}
model_config = {"env_prefix": "ORDERS_", "env_file": ".env", "extra": "ignore"}
# Export for auto-discovery

View File

@@ -23,7 +23,7 @@ class ModuleConfig(BaseSettings):
# api_timeout: int = 30
# batch_size: int = 100
model_config = {"env_prefix": "PAYMENTS_"}
model_config = {"env_prefix": "PAYMENTS_", "env_file": ".env", "extra": "ignore"}
# Export for auto-discovery

View File

@@ -26,7 +26,7 @@ class ModuleConfig(BaseSettings):
# Max concurrent HTTP requests for batch scanning
max_concurrent_requests: int = 10
model_config = {"env_prefix": "PROSPECTING_"}
model_config = {"env_prefix": "PROSPECTING_", "env_file": ".env", "extra": "ignore"}
# Export for auto-discovery