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
|
||||
|
||||
Reference in New Issue
Block a user