# app/modules/loyalty/services/google_wallet_service.py """ Google Wallet service. Handles Google Wallet integration including: - Creating LoyaltyClass for programs - 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 import requests from sqlalchemy.orm import Session from app.core.config import settings from app.modules.loyalty.exceptions import ( GoogleWalletNotConfiguredException, WalletIntegrationException, ) 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.""" def __init__(self): """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 ) 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 (OSError, ValueError) as exc: result["errors"].append(f"Failed to load credentials: {exc}") return result def _get_credentials(self): """Get Google service account credentials.""" if self._credentials: return self._credentials if not settings.loyalty_google_service_account_json: raise GoogleWalletNotConfiguredException() try: from google.oauth2 import service_account 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, ) ) return self._credentials 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 (ValueError, OSError, KeyError) as exc: logger.error("Failed to create RSA signer: %s", exc) raise WalletIntegrationException("google", str(exc)) from exc def _get_http_client(self): """Get authenticated HTTP client.""" if self._http_client: return self._http_client try: from google.auth.transport.requests import AuthorizedSession credentials = self._get_credentials() self._http_client = AuthorizedSession(credentials) return self._http_client except (ValueError, TypeError, AttributeError) as exc: logger.error("Failed to create Google HTTP client: %s", exc) raise WalletIntegrationException("google", str(exc)) from exc # ========================================================================= # LoyaltyClass Operations (Program-level) # ========================================================================= @_retry_on_failure def create_class(self, db: Session, program: LoyaltyProgram) -> str: """ Create a LoyaltyClass for a loyalty program. Args: db: Database session program: Loyalty program Returns: Google Wallet class ID """ if not self.is_configured: raise GoogleWalletNotConfiguredException() 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, "issuerName": issuer_name, "reviewStatus": "DRAFT", "programName": program.display_name, "hexBackgroundColor": program.card_color or "#4285F4", "localizedProgramName": { "defaultValue": { "language": "en", "value": program.display_name, }, }, } # 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"] = { "sourceUri": {"uri": program.hero_image_url}, } try: http = self._get_http_client() response = http.post( "https://walletobjects.googleapis.com/walletobjects/v1/loyaltyClass", json=class_data, ) if response.status_code in (200, 201): program.google_class_id = class_id db.commit() logger.info( "Created Google Wallet class %s for program %s", class_id, program.id, ) return class_id if response.status_code == 409: program.google_class_id = class_id db.commit() return class_id error = response.json() if response.text else {} raise WalletIntegrationException( "google", f"Failed to create class: {response.status_code} - {error}", ) except WalletIntegrationException: raise except (requests.RequestException, ValueError, AttributeError) as exc: logger.error("Failed to create Google Wallet class: %s", exc) raise WalletIntegrationException("google", str(exc)) from 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: return class_data = { "programName": program.display_name, "hexBackgroundColor": program.card_color, } if program.logo_url: class_data["programLogo"] = { "sourceUri": {"uri": program.logo_url}, } try: http = self._get_http_client() response = http.patch( "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( "Failed to update Google Wallet class %s: %s", program.google_class_id, response.status_code, ) except (requests.RequestException, ValueError, AttributeError) as exc: 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. Args: db: Database session card: Loyalty card Returns: Google Wallet object ID """ if not self.is_configured: raise GoogleWalletNotConfiguredException() program = card.program if not program.google_class_id: self.create_class(db, program) issuer_id = settings.loyalty_google_issuer_id object_id = f"{issuer_id}.loyalty_card_{card.id}" object_data = self._build_object_data(card, object_id) try: http = self._get_http_client() response = http.post( "https://walletobjects.googleapis.com/walletobjects/v1/loyaltyObject", json=object_data, ) if response.status_code in (200, 201): card.google_object_id = object_id db.commit() logger.info( "Created Google Wallet object %s for card %s", object_id, card.id, ) return object_id if response.status_code == 409: card.google_object_id = object_id db.commit() return object_id error = response.json() if response.text else {} raise WalletIntegrationException( "google", f"Failed to create object: {response.status_code} - {error}", ) except WalletIntegrationException: raise except (requests.RequestException, ValueError, AttributeError) as exc: logger.error("Failed to create Google Wallet object: %s", exc) raise WalletIntegrationException("google", str(exc)) from 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: return object_data = self._build_object_data(card, card.google_object_id) try: http = self._get_http_client() response = http.patch( "https://walletobjects.googleapis.com/walletobjects/v1/loyaltyObject/" f"{card.google_object_id}", json=object_data, ) if response.status_code in (200, 201): logger.debug( "Updated Google Wallet object for card %s", card.id ) else: logger.warning( "Failed to update Google Wallet object %s: %s", card.google_object_id, response.status_code, ) except (requests.RequestException, ValueError, AttributeError) as exc: logger.error("Failed to update Google Wallet object: %s", exc) def _build_object_data( self, card: LoyaltyCard, object_id: str ) -> dict[str, Any]: """Build the LoyaltyObject data structure.""" program = card.program object_data: dict[str, Any] = { "id": object_id, "classId": program.google_class_id, "state": "ACTIVE" if card.is_active else "INACTIVE", "accountId": card.card_number, "accountName": card.card_number, "barcode": { "type": "CODE_128", "value": card.card_number.replace("-", ""), "alternateText": card.card_number, }, } if program.is_stamps_enabled: object_data["loyaltyPoints"] = { "label": "Stamps", "balance": { "int": card.stamp_count, }, } object_data["secondaryLoyaltyPoints"] = { "label": f"of {program.stamps_target}", "balance": { "int": program.stamps_target, }, } elif program.is_points_enabled: object_data["loyaltyPoints"] = { "label": "Points", "balance": { "int": card.points_balance, }, } return object_data # ========================================================================= # Save URL Generation # ========================================================================= def get_save_url(self, db: Session, card: LoyaltyCard) -> str: """ 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 Returns: URL for adding pass to Google Wallet """ if not self.is_configured: raise GoogleWalletNotConfiguredException() # Try to create the object via API. If it fails (e.g. demo mode # where DRAFT classes reject object creation), fall back to # embedding the full object data in the JWT ("fat JWT"). if not card.google_object_id: try: self.create_object(db, card) except WalletIntegrationException: logger.info( "Object creation failed for card %s, using fat JWT", card.id, ) try: import jwt credentials = self._get_credentials() now = datetime.now(tz=UTC) origins = settings.loyalty_google_wallet_origins or [] issuer_id = settings.loyalty_google_issuer_id object_id = card.google_object_id or f"{issuer_id}.loyalty_card_{card.id}" if card.google_object_id: # Object exists in Google — reference by ID only payload = { "loyaltyObjects": [{"id": card.google_object_id}], } else: # Object not created — embed full object data in JWT object_data = self._build_object_data(card, object_id) payload = { "loyaltyObjects": [object_data], } claims = { "iss": credentials.service_account_email, "aud": "google", "origins": origins, "typ": "savetowallet", "payload": payload, "iat": now, "exp": now + timedelta(hours=1), } # Load the private key directly from the service account file # (RSASigner doesn't expose .key; PyJWT needs the PEM string) with open(settings.loyalty_google_service_account_json) as f: sa_data = json.load(f) private_key = sa_data["private_key"] token = jwt.encode( claims, private_key, algorithm="RS256", ) card.google_object_jwt = token db.commit() return f"https://pay.google.com/gp/v/save/{token}" except (AttributeError, ValueError, KeyError, OSError) as exc: logger.error( "Failed to generate Google Wallet save URL: %s", exc ) raise WalletIntegrationException("google", str(exc)) from 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 (requests.RequestException, ValueError, AttributeError) as exc: logger.error( "Failed to get Google Wallet class status: %s", exc ) return None # Singleton instance google_wallet_service = GoogleWalletService()