feat(loyalty): fix Google Wallet integration and improve enrollment flow
- Fix Google Wallet class creation: add required issuerName field (merchant name), programLogo with default logo fallback, hexBackgroundColor default - Add default loyalty logo assets (200px + 512px) for programs without custom logos - Smart retry: skip retries on 400/401/403/404 client errors (not transient) - Fix enrollment success page: use sessionStorage for wallet URLs instead of authenticated API call (self-enrolled customers have no session) - Hide wallet section on success page when no wallet URLs available - Wire up T&C modal on enrollment page with program.terms_text - Add startup validation for Google/Apple Wallet configs in lifespan - Add admin wallet status dashboard endpoint and UI (moved to service layer) - Fix Apple Wallet push notifications with real APNs HTTP/2 implementation - Fix docs: correct enrollment URLs (port, path segments, /v1 prefix) - Fix test assertion: !loyalty-enroll! → !enrollment! Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,34 @@ class AppleWalletService:
|
||||
and config.apple_signer_key_path
|
||||
)
|
||||
|
||||
def validate_config(self) -> dict[str, Any]:
|
||||
"""Validate Apple Wallet configuration."""
|
||||
import os
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"configured": self.is_configured,
|
||||
"pass_type_id": config.apple_pass_type_id,
|
||||
"team_id": config.apple_team_id,
|
||||
"credentials_valid": False,
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
if not self.is_configured:
|
||||
return result
|
||||
|
||||
for label, path in [
|
||||
("WWDR certificate", config.apple_wwdr_cert_path),
|
||||
("Signer certificate", config.apple_signer_cert_path),
|
||||
("Signer key", config.apple_signer_key_path),
|
||||
]:
|
||||
if not os.path.isfile(path):
|
||||
result["errors"].append(f"{label} not found: {path}")
|
||||
|
||||
if not result["errors"]:
|
||||
result["credentials_valid"] = True
|
||||
|
||||
return result
|
||||
|
||||
# =========================================================================
|
||||
# Auth Verification
|
||||
# =========================================================================
|
||||
@@ -628,18 +656,71 @@ class AppleWalletService:
|
||||
"""
|
||||
Send an empty push notification to trigger pass update.
|
||||
|
||||
Apple Wallet will then call our web service to fetch the updated pass.
|
||||
Apple Wallet will call our web service to fetch the updated pass.
|
||||
Uses APNs HTTP/2 API with certificate-based authentication.
|
||||
"""
|
||||
# This would use APNs to send the push notification
|
||||
# For now, we'll log and skip the actual push
|
||||
logger.debug(f"Would send push to token {push_token[:8]}...")
|
||||
if not self.is_configured:
|
||||
logger.debug("Apple Wallet not configured, skipping push")
|
||||
return
|
||||
|
||||
# In production, you would use something like:
|
||||
# from apns2.client import APNsClient
|
||||
# from apns2.payload import Payload
|
||||
# client = APNsClient(config.apple_signer_cert_path, use_sandbox=True)
|
||||
# payload = Payload()
|
||||
# client.send_notification(push_token, payload, "pass.com.example.loyalty")
|
||||
import ssl
|
||||
|
||||
import httpx
|
||||
|
||||
# APNs endpoint (use sandbox for dev, production for prod)
|
||||
from app.core.config import is_production
|
||||
|
||||
if is_production():
|
||||
apns_host = "https://api.push.apple.com"
|
||||
else:
|
||||
apns_host = "https://api.sandbox.push.apple.com"
|
||||
|
||||
url = f"{apns_host}/3/device/{push_token}"
|
||||
|
||||
# Create SSL context with client certificate
|
||||
ssl_context = ssl.create_default_context()
|
||||
ssl_context.load_cert_chain(
|
||||
certfile=config.apple_signer_cert_path,
|
||||
keyfile=config.apple_signer_key_path,
|
||||
)
|
||||
|
||||
# APNs requires empty payload for pass updates
|
||||
headers = {
|
||||
"apns-topic": config.apple_pass_type_id,
|
||||
"apns-push-type": "background",
|
||||
"apns-priority": "5",
|
||||
}
|
||||
|
||||
try:
|
||||
with httpx.Client(
|
||||
http2=True,
|
||||
verify=ssl_context,
|
||||
timeout=10.0,
|
||||
) as client:
|
||||
response = client.post(
|
||||
url,
|
||||
headers=headers,
|
||||
content=b"{}",
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.debug(
|
||||
"APNs push sent to token %s...", push_token[:8]
|
||||
)
|
||||
elif response.status_code == 410:
|
||||
logger.info(
|
||||
"APNs token %s... is no longer valid (device unregistered)",
|
||||
push_token[:8],
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"APNs push failed for token %s...: %s %s",
|
||||
push_token[:8],
|
||||
response.status_code,
|
||||
response.text,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("APNs push error for token %s...: %s", push_token[:8], exc)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
|
||||
@@ -7,9 +7,14 @@ Handles Google Wallet integration including:
|
||||
- Creating LoyaltyObject for cards
|
||||
- Updating objects on balance changes
|
||||
- Generating "Add to Wallet" URLs
|
||||
- Startup config validation
|
||||
- Retry logic for transient API failures
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -23,6 +28,51 @@ from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Retry configuration
|
||||
MAX_RETRIES = 3
|
||||
RETRY_BACKOFF_BASE = 1 # seconds
|
||||
|
||||
|
||||
def _retry_on_failure(func):
|
||||
"""Decorator that retries Google Wallet API calls on transient failures.
|
||||
|
||||
Only retries on 5xx/network errors. 4xx errors (bad request, not found)
|
||||
are not retryable and fail immediately.
|
||||
"""
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
last_exception = None
|
||||
for attempt in range(MAX_RETRIES):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except WalletIntegrationException as exc:
|
||||
last_exception = exc
|
||||
# Don't retry client errors (400, 401, 403, 404, 409)
|
||||
exc_msg = str(exc)
|
||||
if any(f":{code}" in exc_msg or f" {code} " in exc_msg
|
||||
for code in ("400", "401", "403", "404")):
|
||||
logger.error("Google Wallet API client error (not retryable): %s", exc)
|
||||
break
|
||||
if attempt < MAX_RETRIES - 1:
|
||||
wait = RETRY_BACKOFF_BASE * (2**attempt)
|
||||
logger.warning(
|
||||
"Google Wallet API failed (attempt %d/%d), retrying in %ds: %s",
|
||||
attempt + 1,
|
||||
MAX_RETRIES,
|
||||
wait,
|
||||
exc,
|
||||
)
|
||||
time.sleep(wait)
|
||||
else:
|
||||
logger.error(
|
||||
"Google Wallet API failed after %d attempts: %s",
|
||||
MAX_RETRIES,
|
||||
exc,
|
||||
)
|
||||
raise last_exception # type: ignore[misc]
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class GoogleWalletService:
|
||||
"""Service for Google Wallet integration."""
|
||||
@@ -31,11 +81,70 @@ class GoogleWalletService:
|
||||
"""Initialize the Google Wallet service."""
|
||||
self._credentials = None
|
||||
self._http_client = None
|
||||
self._signer = None
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
"""Check if Google Wallet is configured."""
|
||||
return bool(settings.loyalty_google_issuer_id and settings.loyalty_google_service_account_json)
|
||||
return bool(
|
||||
settings.loyalty_google_issuer_id
|
||||
and settings.loyalty_google_service_account_json
|
||||
)
|
||||
|
||||
def validate_config(self) -> dict[str, Any]:
|
||||
"""
|
||||
Validate Google Wallet configuration at startup.
|
||||
|
||||
Returns:
|
||||
Dict with validation results including any errors found.
|
||||
"""
|
||||
import os
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"configured": self.is_configured,
|
||||
"issuer_id": settings.loyalty_google_issuer_id,
|
||||
"service_account_path": settings.loyalty_google_service_account_json,
|
||||
"credentials_valid": False,
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
if not self.is_configured:
|
||||
return result
|
||||
|
||||
sa_path = settings.loyalty_google_service_account_json
|
||||
if not os.path.isfile(sa_path):
|
||||
result["errors"].append(f"Service account file not found: {sa_path}")
|
||||
return result
|
||||
|
||||
try:
|
||||
with open(sa_path) as f:
|
||||
sa_data = json.load(f)
|
||||
|
||||
required_fields = ["type", "project_id", "private_key", "client_email"]
|
||||
for field in required_fields:
|
||||
if field not in sa_data:
|
||||
result["errors"].append(
|
||||
f"Missing field in service account JSON: {field}"
|
||||
)
|
||||
|
||||
if sa_data.get("type") != "service_account":
|
||||
result["errors"].append(
|
||||
f"Invalid credential type: {sa_data.get('type')} "
|
||||
f"(expected 'service_account')"
|
||||
)
|
||||
|
||||
if not result["errors"]:
|
||||
self._get_credentials()
|
||||
result["credentials_valid"] = True
|
||||
result["service_account_email"] = sa_data.get("client_email")
|
||||
result["project_id"] = sa_data.get("project_id")
|
||||
|
||||
except json.JSONDecodeError as exc:
|
||||
result["errors"].append(f"Invalid JSON in service account file: {exc}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result["errors"].append(f"Failed to load credentials: {exc}")
|
||||
|
||||
return result
|
||||
|
||||
def _get_credentials(self):
|
||||
"""Get Google service account credentials."""
|
||||
@@ -50,14 +159,32 @@ class GoogleWalletService:
|
||||
|
||||
scopes = ["https://www.googleapis.com/auth/wallet_object.issuer"]
|
||||
|
||||
self._credentials = service_account.Credentials.from_service_account_file(
|
||||
settings.loyalty_google_service_account_json,
|
||||
scopes=scopes,
|
||||
self._credentials = (
|
||||
service_account.Credentials.from_service_account_file(
|
||||
settings.loyalty_google_service_account_json,
|
||||
scopes=scopes,
|
||||
)
|
||||
)
|
||||
return self._credentials
|
||||
except (ValueError, OSError) as e:
|
||||
logger.error(f"Failed to load Google credentials: {e}")
|
||||
raise WalletIntegrationException("google", str(e))
|
||||
except (ValueError, OSError) as exc:
|
||||
logger.error("Failed to load Google credentials: %s", exc)
|
||||
raise WalletIntegrationException("google", str(exc))
|
||||
|
||||
def _get_signer(self):
|
||||
"""Get RSA signer from service account for JWT signing."""
|
||||
if self._signer:
|
||||
return self._signer
|
||||
|
||||
try:
|
||||
from google.auth.crypt import RSASigner
|
||||
|
||||
self._signer = RSASigner.from_service_account_file(
|
||||
settings.loyalty_google_service_account_json,
|
||||
)
|
||||
return self._signer
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Failed to create RSA signer: %s", exc)
|
||||
raise WalletIntegrationException("google", str(exc))
|
||||
|
||||
def _get_http_client(self):
|
||||
"""Get authenticated HTTP client."""
|
||||
@@ -70,14 +197,15 @@ class GoogleWalletService:
|
||||
credentials = self._get_credentials()
|
||||
self._http_client = AuthorizedSession(credentials)
|
||||
return self._http_client
|
||||
except Exception as e: # noqa: EXC003
|
||||
logger.error(f"Failed to create Google HTTP client: {e}")
|
||||
raise WalletIntegrationException("google", str(e))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Failed to create Google HTTP client: %s", exc)
|
||||
raise WalletIntegrationException("google", str(exc))
|
||||
|
||||
# =========================================================================
|
||||
# LoyaltyClass Operations (Program-level)
|
||||
# =========================================================================
|
||||
|
||||
@_retry_on_failure
|
||||
def create_class(self, db: Session, program: LoyaltyProgram) -> str:
|
||||
"""
|
||||
Create a LoyaltyClass for a loyalty program.
|
||||
@@ -95,17 +223,16 @@ class GoogleWalletService:
|
||||
issuer_id = settings.loyalty_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,
|
||||
"reviewStatus": "UNDER_REVIEW",
|
||||
"issuerName": issuer_name,
|
||||
"reviewStatus": "DRAFT",
|
||||
"programName": program.display_name,
|
||||
"programLogo": {
|
||||
"sourceUri": {
|
||||
"uri": program.logo_url or "https://via.placeholder.com/100",
|
||||
},
|
||||
},
|
||||
"hexBackgroundColor": program.card_color,
|
||||
"hexBackgroundColor": program.card_color or "#4285F4",
|
||||
"localizedProgramName": {
|
||||
"defaultValue": {
|
||||
"language": "en",
|
||||
@@ -114,6 +241,15 @@ class GoogleWalletService:
|
||||
},
|
||||
}
|
||||
|
||||
# programLogo is required by Google Wallet API
|
||||
# Google must be able to fetch the image, so it needs a public URL
|
||||
logo_url = program.logo_url
|
||||
if not logo_url:
|
||||
logo_url = settings.loyalty_default_logo_url
|
||||
class_data["programLogo"] = {
|
||||
"sourceUri": {"uri": logo_url},
|
||||
}
|
||||
|
||||
# Add hero image if configured
|
||||
if program.hero_image_url:
|
||||
class_data["heroImage"] = {
|
||||
@@ -128,14 +264,15 @@ class GoogleWalletService:
|
||||
)
|
||||
|
||||
if response.status_code in (200, 201):
|
||||
# Update program with class ID
|
||||
program.google_class_id = class_id
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Created Google Wallet class {class_id} for program {program.id}")
|
||||
logger.info(
|
||||
"Created Google Wallet class %s for program %s",
|
||||
class_id,
|
||||
program.id,
|
||||
)
|
||||
return class_id
|
||||
if response.status_code == 409:
|
||||
# Class already exists
|
||||
program.google_class_id = class_id
|
||||
db.commit()
|
||||
return class_id
|
||||
@@ -146,10 +283,11 @@ class GoogleWalletService:
|
||||
)
|
||||
except WalletIntegrationException:
|
||||
raise
|
||||
except Exception as e: # noqa: EXC003
|
||||
logger.error(f"Failed to create Google Wallet class: {e}")
|
||||
raise WalletIntegrationException("google", str(e))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Failed to create Google Wallet class: %s", exc)
|
||||
raise WalletIntegrationException("google", str(exc))
|
||||
|
||||
@_retry_on_failure
|
||||
def update_class(self, db: Session, program: LoyaltyProgram) -> None:
|
||||
"""Update a LoyaltyClass when program settings change."""
|
||||
if not program.google_class_id:
|
||||
@@ -168,22 +306,25 @@ class GoogleWalletService:
|
||||
try:
|
||||
http = self._get_http_client()
|
||||
response = http.patch(
|
||||
f"https://walletobjects.googleapis.com/walletobjects/v1/loyaltyClass/{program.google_class_id}",
|
||||
"https://walletobjects.googleapis.com/walletobjects/v1/loyaltyClass/"
|
||||
f"{program.google_class_id}",
|
||||
json=class_data,
|
||||
)
|
||||
|
||||
if response.status_code not in (200, 201):
|
||||
logger.warning(
|
||||
f"Failed to update Google Wallet class {program.google_class_id}: "
|
||||
f"{response.status_code}"
|
||||
"Failed to update Google Wallet class %s: %s",
|
||||
program.google_class_id,
|
||||
response.status_code,
|
||||
)
|
||||
except Exception as e: # noqa: EXC003
|
||||
logger.error(f"Failed to update Google Wallet class: {e}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Failed to update Google Wallet class: %s", exc)
|
||||
|
||||
# =========================================================================
|
||||
# LoyaltyObject Operations (Card-level)
|
||||
# =========================================================================
|
||||
|
||||
@_retry_on_failure
|
||||
def create_object(self, db: Session, card: LoyaltyCard) -> str:
|
||||
"""
|
||||
Create a LoyaltyObject for a loyalty card.
|
||||
@@ -200,7 +341,6 @@ class GoogleWalletService:
|
||||
|
||||
program = card.program
|
||||
if not program.google_class_id:
|
||||
# Create class first
|
||||
self.create_class(db, program)
|
||||
|
||||
issuer_id = settings.loyalty_google_issuer_id
|
||||
@@ -218,11 +358,13 @@ class GoogleWalletService:
|
||||
if response.status_code in (200, 201):
|
||||
card.google_object_id = object_id
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Created Google Wallet object {object_id} for card {card.id}")
|
||||
logger.info(
|
||||
"Created Google Wallet object %s for card %s",
|
||||
object_id,
|
||||
card.id,
|
||||
)
|
||||
return object_id
|
||||
if response.status_code == 409:
|
||||
# Object already exists
|
||||
card.google_object_id = object_id
|
||||
db.commit()
|
||||
return object_id
|
||||
@@ -233,10 +375,11 @@ class GoogleWalletService:
|
||||
)
|
||||
except WalletIntegrationException:
|
||||
raise
|
||||
except Exception as e: # noqa: EXC003
|
||||
logger.error(f"Failed to create Google Wallet object: {e}")
|
||||
raise WalletIntegrationException("google", str(e))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Failed to create Google Wallet object: %s", exc)
|
||||
raise WalletIntegrationException("google", str(exc))
|
||||
|
||||
@_retry_on_failure
|
||||
def update_object(self, db: Session, card: LoyaltyCard) -> None:
|
||||
"""Update a LoyaltyObject when card balance changes."""
|
||||
if not card.google_object_id:
|
||||
@@ -247,25 +390,31 @@ class GoogleWalletService:
|
||||
try:
|
||||
http = self._get_http_client()
|
||||
response = http.patch(
|
||||
f"https://walletobjects.googleapis.com/walletobjects/v1/loyaltyObject/{card.google_object_id}",
|
||||
"https://walletobjects.googleapis.com/walletobjects/v1/loyaltyObject/"
|
||||
f"{card.google_object_id}",
|
||||
json=object_data,
|
||||
)
|
||||
|
||||
if response.status_code in (200, 201):
|
||||
logger.debug(f"Updated Google Wallet object for card {card.id}")
|
||||
logger.debug(
|
||||
"Updated Google Wallet object for card %s", card.id
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Failed to update Google Wallet object {card.google_object_id}: "
|
||||
f"{response.status_code}"
|
||||
"Failed to update Google Wallet object %s: %s",
|
||||
card.google_object_id,
|
||||
response.status_code,
|
||||
)
|
||||
except Exception as e: # noqa: EXC003
|
||||
logger.error(f"Failed to update Google Wallet object: {e}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Failed to update Google Wallet object: %s", exc)
|
||||
|
||||
def _build_object_data(self, card: LoyaltyCard, object_id: str) -> dict[str, Any]:
|
||||
def _build_object_data(
|
||||
self, card: LoyaltyCard, object_id: str
|
||||
) -> dict[str, Any]:
|
||||
"""Build the LoyaltyObject data structure."""
|
||||
program = card.program
|
||||
|
||||
object_data = {
|
||||
object_data: dict[str, Any] = {
|
||||
"id": object_id,
|
||||
"classId": program.google_class_id,
|
||||
"state": "ACTIVE" if card.is_active else "INACTIVE",
|
||||
@@ -278,7 +427,6 @@ class GoogleWalletService:
|
||||
},
|
||||
}
|
||||
|
||||
# Add loyalty points (stamps as points for display)
|
||||
if program.is_stamps_enabled:
|
||||
object_data["loyaltyPoints"] = {
|
||||
"label": "Stamps",
|
||||
@@ -286,7 +434,6 @@ class GoogleWalletService:
|
||||
"int": card.stamp_count,
|
||||
},
|
||||
}
|
||||
# Add secondary points showing target
|
||||
object_data["secondaryLoyaltyPoints"] = {
|
||||
"label": f"of {program.stamps_target}",
|
||||
"balance": {
|
||||
@@ -311,6 +458,9 @@ class GoogleWalletService:
|
||||
"""
|
||||
Get the "Add to Google Wallet" URL for a card.
|
||||
|
||||
Uses google.auth.crypt.RSASigner (public API) for JWT signing
|
||||
instead of accessing private signer internals.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
card: Loyalty card
|
||||
@@ -321,34 +471,34 @@ class GoogleWalletService:
|
||||
if not self.is_configured:
|
||||
raise GoogleWalletNotConfiguredException()
|
||||
|
||||
# Ensure object exists
|
||||
if not card.google_object_id:
|
||||
self.create_object(db, card)
|
||||
|
||||
# Generate JWT for save link
|
||||
try:
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import jwt
|
||||
|
||||
credentials = self._get_credentials()
|
||||
signer = self._get_signer()
|
||||
|
||||
now = datetime.now(tz=UTC)
|
||||
origins = settings.loyalty_google_wallet_origins or []
|
||||
|
||||
claims = {
|
||||
"iss": credentials.service_account_email,
|
||||
"aud": "google",
|
||||
"origins": [],
|
||||
"origins": origins,
|
||||
"typ": "savetowallet",
|
||||
"payload": {
|
||||
"loyaltyObjects": [{"id": card.google_object_id}],
|
||||
},
|
||||
"iat": datetime.utcnow(),
|
||||
"exp": datetime.utcnow() + timedelta(hours=1),
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=1),
|
||||
}
|
||||
|
||||
# Sign with service account private key
|
||||
# Sign using the RSASigner's key_id and key bytes (public API)
|
||||
token = jwt.encode(
|
||||
claims,
|
||||
credentials._signer._key,
|
||||
signer.key,
|
||||
algorithm="RS256",
|
||||
)
|
||||
|
||||
@@ -356,9 +506,49 @@ class GoogleWalletService:
|
||||
db.commit()
|
||||
|
||||
return f"https://pay.google.com/gp/v/save/{token}"
|
||||
except Exception as e: # noqa: EXC003
|
||||
logger.error(f"Failed to generate Google Wallet save URL: {e}")
|
||||
raise WalletIntegrationException("google", str(e))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error(
|
||||
"Failed to generate Google Wallet save URL: %s", exc
|
||||
)
|
||||
raise WalletIntegrationException("google", str(exc))
|
||||
|
||||
# =========================================================================
|
||||
# Class Approval
|
||||
# =========================================================================
|
||||
|
||||
def get_class_status(self, class_id: str) -> dict[str, Any] | None:
|
||||
"""
|
||||
Check the review status of a LoyaltyClass.
|
||||
|
||||
Args:
|
||||
class_id: Google Wallet class ID
|
||||
|
||||
Returns:
|
||||
Dict with class status info or None if not found.
|
||||
"""
|
||||
if not self.is_configured:
|
||||
return None
|
||||
|
||||
try:
|
||||
http = self._get_http_client()
|
||||
response = http.get(
|
||||
"https://walletobjects.googleapis.com/walletobjects/v1/loyaltyClass/"
|
||||
f"{class_id}",
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return {
|
||||
"class_id": class_id,
|
||||
"review_status": data.get("reviewStatus"),
|
||||
"program_name": data.get("programName"),
|
||||
}
|
||||
return None
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error(
|
||||
"Failed to get Google Wallet class status: %s", exc
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
# Singleton instance
|
||||
|
||||
@@ -833,6 +833,62 @@ class ProgramService:
|
||||
"estimated_liability_cents": estimated_liability,
|
||||
}
|
||||
|
||||
def get_wallet_integration_status(self, db: Session) -> dict:
|
||||
"""Get wallet integration status for admin dashboard."""
|
||||
from app.modules.loyalty.models import LoyaltyCard
|
||||
from app.modules.loyalty.services.apple_wallet_service import (
|
||||
apple_wallet_service,
|
||||
)
|
||||
from app.modules.loyalty.services.google_wallet_service import (
|
||||
google_wallet_service,
|
||||
)
|
||||
|
||||
# Google Wallet
|
||||
google_config = google_wallet_service.validate_config()
|
||||
google_classes = []
|
||||
if google_config["credentials_valid"]:
|
||||
programs_with_class = (
|
||||
db.query(LoyaltyProgram)
|
||||
.filter(LoyaltyProgram.google_class_id.isnot(None))
|
||||
.all()
|
||||
)
|
||||
for prog in programs_with_class:
|
||||
status = google_wallet_service.get_class_status(
|
||||
prog.google_class_id,
|
||||
)
|
||||
google_classes.append({
|
||||
"program_id": prog.id,
|
||||
"program_name": prog.display_name,
|
||||
"class_id": prog.google_class_id,
|
||||
"review_status": status["review_status"] if status else "UNKNOWN",
|
||||
})
|
||||
|
||||
google_objects = (
|
||||
db.query(LoyaltyCard)
|
||||
.filter(LoyaltyCard.google_object_id.isnot(None))
|
||||
.count()
|
||||
)
|
||||
|
||||
# Apple Wallet
|
||||
apple_config = apple_wallet_service.validate_config()
|
||||
apple_passes = (
|
||||
db.query(LoyaltyCard)
|
||||
.filter(LoyaltyCard.apple_serial_number.isnot(None))
|
||||
.count()
|
||||
)
|
||||
|
||||
return {
|
||||
"google_wallet": {
|
||||
**google_config,
|
||||
"classes": google_classes,
|
||||
"total_objects": google_objects,
|
||||
},
|
||||
"apple_wallet": {
|
||||
**apple_config,
|
||||
"total_passes": apple_passes,
|
||||
},
|
||||
}
|
||||
|
||||
def get_merchant_stats(self, db: Session, merchant_id: int) -> dict:
|
||||
"""
|
||||
Get statistics for a merchant's loyalty program across all locations.
|
||||
|
||||
Reference in New Issue
Block a user