# 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 """ import logging from typing import Any 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__) class GoogleWalletService: """Service for Google Wallet integration.""" def __init__(self): """Initialize the Google Wallet service.""" self._credentials = None self._http_client = 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 _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 e: logger.error(f"Failed to load Google credentials: {e}") raise WalletIntegrationException("google", str(e)) 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 Exception as e: # noqa: EXC003 logger.error(f"Failed to create Google HTTP client: {e}") raise WalletIntegrationException("google", str(e)) # ========================================================================= # LoyaltyClass Operations (Program-level) # ========================================================================= 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}" class_data = { "id": class_id, "issuerId": issuer_id, "reviewStatus": "UNDER_REVIEW", "programName": program.display_name, "programLogo": { "sourceUri": { "uri": program.logo_url or "https://via.placeholder.com/100", }, }, "hexBackgroundColor": program.card_color, "localizedProgramName": { "defaultValue": { "language": "en", "value": program.display_name, }, }, } # 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): # 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}") return class_id if response.status_code == 409: # Class already exists 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 Exception as e: # noqa: EXC003 logger.error(f"Failed to create Google Wallet class: {e}") raise WalletIntegrationException("google", str(e)) 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( f"https://walletobjects.googleapis.com/walletobjects/v1/loyaltyClass/{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}" ) except Exception as e: # noqa: EXC003 logger.error(f"Failed to update Google Wallet class: {e}") # ========================================================================= # LoyaltyObject Operations (Card-level) # ========================================================================= 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: # Create class first 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(f"Created Google Wallet object {object_id} for card {card.id}") return object_id if response.status_code == 409: # Object already exists 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 Exception as e: # noqa: EXC003 logger.error(f"Failed to create Google Wallet object: {e}") raise WalletIntegrationException("google", str(e)) 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( f"https://walletobjects.googleapis.com/walletobjects/v1/loyaltyObject/{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}") else: logger.warning( f"Failed to update Google Wallet object {card.google_object_id}: " f"{response.status_code}" ) except Exception as e: # noqa: EXC003 logger.error(f"Failed to update Google Wallet object: {e}") def _build_object_data(self, card: LoyaltyCard, object_id: str) -> dict[str, Any]: """Build the LoyaltyObject data structure.""" program = card.program object_data = { "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, }, } # Add loyalty points (stamps as points for display) if program.is_stamps_enabled: object_data["loyaltyPoints"] = { "label": "Stamps", "balance": { "int": card.stamp_count, }, } # Add secondary points showing target 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. Args: db: Database session card: Loyalty card Returns: URL for adding pass to Google Wallet """ 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() claims = { "iss": credentials.service_account_email, "aud": "google", "origins": [], "typ": "savetowallet", "payload": { "loyaltyObjects": [{"id": card.google_object_id}], }, "iat": datetime.utcnow(), "exp": datetime.utcnow() + timedelta(hours=1), } # Sign with service account private key token = jwt.encode( claims, credentials._signer._key, algorithm="RS256", ) card.google_object_jwt = token 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)) # Singleton instance google_wallet_service = GoogleWalletService()