diff --git a/app/modules/analytics/config.py b/app/modules/analytics/config.py index 2b476148..0ec962c0 100644 --- a/app/modules/analytics/config.py +++ b/app/modules/analytics/config.py @@ -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 diff --git a/app/modules/billing/config.py b/app/modules/billing/config.py index f9bde637..04fb4082 100644 --- a/app/modules/billing/config.py +++ b/app/modules/billing/config.py @@ -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 diff --git a/app/modules/cms/config.py b/app/modules/cms/config.py index 7523d8ee..64d2c114 100644 --- a/app/modules/cms/config.py +++ b/app/modules/cms/config.py @@ -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 diff --git a/app/modules/config.py b/app/modules/config.py index dc1893c2..8207ff5b 100644 --- a/app/modules/config.py +++ b/app/modules/config.py @@ -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 diff --git a/app/modules/customers/config.py b/app/modules/customers/config.py index e738907e..83932651 100644 --- a/app/modules/customers/config.py +++ b/app/modules/customers/config.py @@ -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 diff --git a/app/modules/dev_tools/config.py b/app/modules/dev_tools/config.py index 1053af6a..ffea59c1 100644 --- a/app/modules/dev_tools/config.py +++ b/app/modules/dev_tools/config.py @@ -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 diff --git a/app/modules/dev_tools/services/domain_health_service.py b/app/modules/dev_tools/services/domain_health_service.py index 58dcd53a..ded0e74c 100644 --- a/app/modules/dev_tools/services/domain_health_service.py +++ b/app/modules/dev_tools/services/domain_health_service.py @@ -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} diff --git a/app/modules/dev_tools/tests/unit/test_domain_health_service.py b/app/modules/dev_tools/tests/unit/test_domain_health_service.py new file mode 100644 index 00000000..359a1aa0 --- /dev/null +++ b/app/modules/dev_tools/tests/unit/test_domain_health_service.py @@ -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 diff --git a/app/modules/dev_tools/tests/unit/test_isolation_audit_service.py b/app/modules/dev_tools/tests/unit/test_isolation_audit_service.py new file mode 100644 index 00000000..01fef38c --- /dev/null +++ b/app/modules/dev_tools/tests/unit/test_isolation_audit_service.py @@ -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 diff --git a/app/modules/hosting/config.py b/app/modules/hosting/config.py index 409b1bea..d7265774 100644 --- a/app/modules/hosting/config.py +++ b/app/modules/hosting/config.py @@ -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 diff --git a/app/modules/inventory/config.py b/app/modules/inventory/config.py index 20167e79..6bc5a66a 100644 --- a/app/modules/inventory/config.py +++ b/app/modules/inventory/config.py @@ -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 diff --git a/app/modules/loyalty/config.py b/app/modules/loyalty/config.py index b901117a..9f88b87e 100644 --- a/app/modules/loyalty/config.py +++ b/app/modules/loyalty/config.py @@ -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 diff --git a/app/modules/loyalty/definition.py b/app/modules/loyalty/definition.py index 521a36e2..defcd529 100644 --- a/app/modules/loyalty/definition.py +++ b/app/modules/loyalty/definition.py @@ -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, + ), ], ), ], diff --git a/app/modules/loyalty/locales/de.json b/app/modules/loyalty/locales/de.json index a6377713..515d9164 100644 --- a/app/modules/loyalty/locales/de.json +++ b/app/modules/loyalty/locales/de.json @@ -80,7 +80,8 @@ "statistics": "Statistiken", "program": "Programm", "overview": "Übersicht", - "settings": "Einstellungen" + "settings": "Einstellungen", + "wallet_debug": "Wallet Debug" }, "permissions": { "view_programs": "Programme anzeigen", diff --git a/app/modules/loyalty/locales/en.json b/app/modules/loyalty/locales/en.json index 73385dbd..dc10ff58 100644 --- a/app/modules/loyalty/locales/en.json +++ b/app/modules/loyalty/locales/en.json @@ -90,7 +90,8 @@ "statistics": "Statistics", "program": "Program", "overview": "Overview", - "settings": "Settings" + "settings": "Settings", + "wallet_debug": "Wallet Debug" }, "onboarding": { "create_program": { diff --git a/app/modules/loyalty/locales/fr.json b/app/modules/loyalty/locales/fr.json index 1942bd70..b7de911a 100644 --- a/app/modules/loyalty/locales/fr.json +++ b/app/modules/loyalty/locales/fr.json @@ -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", diff --git a/app/modules/loyalty/locales/lb.json b/app/modules/loyalty/locales/lb.json index b46fbf59..5d2092c6 100644 --- a/app/modules/loyalty/locales/lb.json +++ b/app/modules/loyalty/locales/lb.json @@ -80,7 +80,8 @@ "statistics": "Statistiken", "program": "Programm", "overview": "Iwwersiicht", - "settings": "Astellungen" + "settings": "Astellungen", + "wallet_debug": "Wallet Debug" }, "permissions": { "view_programs": "Programmer kucken", diff --git a/app/modules/loyalty/routes/api/admin.py b/app/modules/loyalty/routes/api/admin.py index 62e77bf6..1c688186 100644 --- a/app/modules/loyalty/routes/api/admin.py +++ b/app/modules/loyalty/routes/api/admin.py @@ -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} diff --git a/app/modules/loyalty/routes/pages/admin.py b/app/modules/loyalty/routes/pages/admin.py index 9d28f840..e16f2b7c 100644 --- a/app/modules/loyalty/routes/pages/admin.py +++ b/app/modules/loyalty/routes/pages/admin.py @@ -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, diff --git a/app/modules/loyalty/schemas/program.py b/app/modules/loyalty/schemas/program.py index 5ea004f6..774bd665 100644 --- a/app/modules/loyalty/schemas/program.py +++ b/app/modules/loyalty/schemas/program.py @@ -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): diff --git a/app/modules/loyalty/services/card_service.py b/app/modules/loyalty/services/card_service.py index 0d81a26b..c01ece46 100644 --- a/app/modules/loyalty/services/card_service.py +++ b/app/modules/loyalty/services/card_service.py @@ -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). diff --git a/app/modules/loyalty/services/google_wallet_service.py b/app/modules/loyalty/services/google_wallet_service.py index 1bc2ad56..1f78a94e 100644 --- a/app/modules/loyalty/services/google_wallet_service.py +++ b/app/modules/loyalty/services/google_wallet_service.py @@ -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], } diff --git a/app/modules/loyalty/templates/loyalty/admin/wallet-debug.html b/app/modules/loyalty/templates/loyalty/admin/wallet-debug.html new file mode 100644 index 00000000..c23de76e --- /dev/null +++ b/app/modules/loyalty/templates/loyalty/admin/wallet-debug.html @@ -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') }} + +
+ Validates Google Wallet configuration: service account, credentials, issuer ID, origins, and logo URL reachability. +
++ Shows Google Wallet class review status for each loyalty program. Classes must be APPROVED for wallet buttons to work. +
++ Inspect a loyalty card's wallet integration state, including Google Wallet object status and JWT details. +
++ Generate a fresh Google Wallet save URL for a card and inspect the JWT. +
++ Shows the last 20 enrollments with their wallet integration status. +
++ Apple Wallet configuration status and pass statistics. +
+Apple Wallet is not configured. Set these environment variables:
+Click "Check Config" to load Apple Wallet status.
+