feat(loyalty): implement complete loyalty module MVP
Add stamp-based and points-based loyalty programs for vendors with: Database Models (5 tables): - loyalty_programs: Vendor program configuration - loyalty_cards: Customer cards with stamp/point balances - loyalty_transactions: Immutable audit log - staff_pins: Fraud prevention PINs (bcrypt hashed) - apple_device_registrations: Apple Wallet push tokens Services: - program_service: Program CRUD and statistics - card_service: Customer enrollment and card lookup - stamp_service: Stamp operations with anti-fraud checks - points_service: Points earning and redemption - pin_service: Staff PIN management with lockout - wallet_service: Unified wallet abstraction - google_wallet_service: Google Wallet API integration - apple_wallet_service: Apple Wallet .pkpass generation API Routes: - Admin: /api/v1/admin/loyalty/* (programs list, stats) - Vendor: /api/v1/vendor/loyalty/* (stamp, points, cards, PINs) - Public: /api/v1/loyalty/* (enrollment, Apple Web Service) Anti-Fraud Features: - Staff PIN verification (configurable per program) - Cooldown period between stamps (default 15 min) - Daily stamp limits (default 5/day) - PIN lockout after failed attempts Wallet Integration: - Google Wallet: LoyaltyClass and LoyaltyObject management - Apple Wallet: .pkpass generation with PKCS#7 signing - Apple Web Service endpoints for device registration/updates Also includes: - Alembic migration for all tables with indexes - Localization files (en, fr, de, lu) - Module documentation - Phase 2 interface and user journey plan Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
366
app/modules/loyalty/services/google_wallet_service.py
Normal file
366
app/modules/loyalty/services/google_wallet_service.py
Normal file
@@ -0,0 +1,366 @@
|
||||
# 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 json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.modules.loyalty.config import config
|
||||
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(config.google_issuer_id and config.google_service_account_json)
|
||||
|
||||
def _get_credentials(self):
|
||||
"""Get Google service account credentials."""
|
||||
if self._credentials:
|
||||
return self._credentials
|
||||
|
||||
if not config.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(
|
||||
config.google_service_account_json,
|
||||
scopes=scopes,
|
||||
)
|
||||
return self._credentials
|
||||
except Exception 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:
|
||||
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 = config.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
|
||||
elif response.status_code == 409:
|
||||
# Class already exists
|
||||
program.google_class_id = class_id
|
||||
db.commit()
|
||||
return class_id
|
||||
else:
|
||||
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:
|
||||
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:
|
||||
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 = config.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
|
||||
elif response.status_code == 409:
|
||||
# Object already exists
|
||||
card.google_object_id = object_id
|
||||
db.commit()
|
||||
return object_id
|
||||
else:
|
||||
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:
|
||||
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:
|
||||
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": "QR_CODE",
|
||||
"value": card.qr_code_data,
|
||||
},
|
||||
}
|
||||
|
||||
# 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:
|
||||
import jwt
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
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:
|
||||
logger.error(f"Failed to generate Google Wallet save URL: {e}")
|
||||
raise WalletIntegrationException("google", str(e))
|
||||
|
||||
|
||||
# Singleton instance
|
||||
google_wallet_service = GoogleWalletService()
|
||||
Reference in New Issue
Block a user