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