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:
2026-03-11 17:32:55 +01:00
parent f766a72480
commit 8c8975239a
15 changed files with 828 additions and 239 deletions

View File

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