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

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

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

View File

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