feat: wire Google Wallet into loyalty enrollment, stamps, and points flows
Connect the fully-implemented Google Wallet service to the loyalty module: - Create wallet class/object on customer enrollment - Sync wallet passes on stamp and points operations - Expose wallet URLs in storefront API responses - Add conditional "Add to Google Wallet" buttons on dashboard and enroll-success pages - Use platform-wide env var config (not per-merchant DB column) - Add Google service account patterns to .gitignore - Add LOYALTY_GOOGLE_* fields to app Settings - Update deployment docs and add local testing guide Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -24,7 +24,7 @@ from app.modules.loyalty.schemas import (
|
||||
CardResponse,
|
||||
ProgramResponse,
|
||||
)
|
||||
from app.modules.loyalty.services import card_service, program_service
|
||||
from app.modules.loyalty.services import card_service, program_service, wallet_service
|
||||
from app.modules.tenancy.exceptions import StoreNotFoundException
|
||||
|
||||
storefront_router = APIRouter()
|
||||
@@ -89,8 +89,32 @@ def self_enroll(
|
||||
logger.info(f"Self-enrollment for customer {customer_id} at store {store.subdomain}")
|
||||
|
||||
card = card_service.enroll_customer_for_store(db, customer_id, store.id)
|
||||
program = card.program
|
||||
wallet_urls = wallet_service.get_add_to_wallet_urls(db, card)
|
||||
|
||||
return CardResponse.model_validate(card)
|
||||
return {
|
||||
"card": CardResponse(
|
||||
id=card.id,
|
||||
card_number=card.card_number,
|
||||
customer_id=card.customer_id,
|
||||
merchant_id=card.merchant_id,
|
||||
program_id=card.program_id,
|
||||
enrolled_at_store_id=card.enrolled_at_store_id,
|
||||
stamp_count=card.stamp_count,
|
||||
stamps_target=program.stamps_target,
|
||||
stamps_until_reward=max(0, program.stamps_target - card.stamp_count),
|
||||
total_stamps_earned=card.total_stamps_earned,
|
||||
stamps_redeemed=card.stamps_redeemed,
|
||||
points_balance=card.points_balance,
|
||||
total_points_earned=card.total_points_earned,
|
||||
points_redeemed=card.points_redeemed,
|
||||
is_active=card.is_active,
|
||||
created_at=card.created_at,
|
||||
has_google_wallet=bool(card.google_object_id),
|
||||
has_apple_wallet=bool(card.apple_serial_number),
|
||||
),
|
||||
"wallet_urls": wallet_urls,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -137,10 +161,32 @@ def get_my_card(
|
||||
program_response.is_points_enabled = program.is_points_enabled
|
||||
program_response.display_name = program.display_name
|
||||
|
||||
wallet_urls = wallet_service.get_add_to_wallet_urls(db, card)
|
||||
|
||||
return {
|
||||
"card": CardResponse.model_validate(card),
|
||||
"card": CardResponse(
|
||||
id=card.id,
|
||||
card_number=card.card_number,
|
||||
customer_id=card.customer_id,
|
||||
merchant_id=card.merchant_id,
|
||||
program_id=card.program_id,
|
||||
enrolled_at_store_id=card.enrolled_at_store_id,
|
||||
stamp_count=card.stamp_count,
|
||||
stamps_target=program.stamps_target,
|
||||
stamps_until_reward=max(0, program.stamps_target - card.stamp_count),
|
||||
total_stamps_earned=card.total_stamps_earned,
|
||||
stamps_redeemed=card.stamps_redeemed,
|
||||
points_balance=card.points_balance,
|
||||
total_points_earned=card.total_points_earned,
|
||||
points_redeemed=card.points_redeemed,
|
||||
is_active=card.is_active,
|
||||
created_at=card.created_at,
|
||||
has_google_wallet=bool(card.google_object_id),
|
||||
has_apple_wallet=bool(card.apple_serial_number),
|
||||
),
|
||||
"program": program_response,
|
||||
"locations": [{"id": v.id, "name": v.name} for v in locations],
|
||||
"wallet_urls": wallet_urls,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -416,6 +416,12 @@ class CardService:
|
||||
db.commit()
|
||||
db.refresh(card)
|
||||
|
||||
# Create wallet objects (Google Wallet, Apple Wallet)
|
||||
# Lazy import to avoid circular imports; exception-safe (logs but doesn't raise)
|
||||
from app.modules.loyalty.services.wallet_service import wallet_service
|
||||
|
||||
wallet_service.create_wallet_objects(db, card)
|
||||
|
||||
logger.info(
|
||||
f"Enrolled customer {customer_id} in merchant {merchant_id} loyalty program "
|
||||
f"(card: {card.card_number}, bonus: {program.welcome_bonus_points} pts)"
|
||||
|
||||
@@ -169,6 +169,11 @@ class PointsService:
|
||||
db.commit()
|
||||
db.refresh(card)
|
||||
|
||||
# Sync wallet passes with updated points balance
|
||||
from app.modules.loyalty.services.wallet_service import wallet_service
|
||||
|
||||
wallet_service.sync_card_to_wallets(db, card)
|
||||
|
||||
logger.info(
|
||||
f"Added {points_earned} points to card {card.id} at store {store_id} "
|
||||
f"(purchase: €{purchase_euros:.2f}, balance: {card.points_balance})"
|
||||
@@ -295,6 +300,11 @@ class PointsService:
|
||||
db.commit()
|
||||
db.refresh(card)
|
||||
|
||||
# Sync wallet passes with updated points balance
|
||||
from app.modules.loyalty.services.wallet_service import wallet_service
|
||||
|
||||
wallet_service.sync_card_to_wallets(db, card)
|
||||
|
||||
logger.info(
|
||||
f"Redeemed {points_required} points from card {card.id} at store {store_id} "
|
||||
f"(reward: {reward_name}, balance: {card.points_balance})"
|
||||
@@ -437,6 +447,11 @@ class PointsService:
|
||||
db.commit()
|
||||
db.refresh(card)
|
||||
|
||||
# Sync wallet passes with updated points balance
|
||||
from app.modules.loyalty.services.wallet_service import wallet_service
|
||||
|
||||
wallet_service.sync_card_to_wallets(db, card)
|
||||
|
||||
logger.info(
|
||||
f"Voided {actual_voided} points from card {card.id} at store {store_id} "
|
||||
f"(balance: {card.points_balance})"
|
||||
@@ -523,6 +538,11 @@ class PointsService:
|
||||
db.commit()
|
||||
db.refresh(card)
|
||||
|
||||
# Sync wallet passes with updated points balance
|
||||
from app.modules.loyalty.services.wallet_service import wallet_service
|
||||
|
||||
wallet_service.sync_card_to_wallets(db, card)
|
||||
|
||||
logger.info(
|
||||
f"Adjusted points for card {card.id} by {points_delta:+d} "
|
||||
f"(reason: {reason}, balance: {card.points_balance})"
|
||||
|
||||
@@ -154,6 +154,11 @@ class StampService:
|
||||
db.commit()
|
||||
db.refresh(card)
|
||||
|
||||
# Sync wallet passes with updated stamp count
|
||||
from app.modules.loyalty.services.wallet_service import wallet_service
|
||||
|
||||
wallet_service.sync_card_to_wallets(db, card)
|
||||
|
||||
stamps_today += 1
|
||||
|
||||
logger.info(
|
||||
@@ -273,6 +278,11 @@ class StampService:
|
||||
db.commit()
|
||||
db.refresh(card)
|
||||
|
||||
# Sync wallet passes with updated stamp count
|
||||
from app.modules.loyalty.services.wallet_service import wallet_service
|
||||
|
||||
wallet_service.sync_card_to_wallets(db, card)
|
||||
|
||||
logger.info(
|
||||
f"Redeemed stamps from card {card.id} at store {store_id} "
|
||||
f"(reward: {program.stamps_reward_description}, "
|
||||
@@ -400,6 +410,11 @@ class StampService:
|
||||
db.commit()
|
||||
db.refresh(card)
|
||||
|
||||
# Sync wallet passes with updated stamp count
|
||||
from app.modules.loyalty.services.wallet_service import wallet_service
|
||||
|
||||
wallet_service.sync_card_to_wallets(db, card)
|
||||
|
||||
logger.info(
|
||||
f"Voided {actual_voided} stamps from card {card.id} at store {store_id} "
|
||||
f"(balance: {card.stamp_count})"
|
||||
|
||||
@@ -47,8 +47,8 @@ class WalletService:
|
||||
|
||||
program = card.program
|
||||
|
||||
# Google Wallet
|
||||
if program.google_issuer_id or program.google_class_id:
|
||||
# Google Wallet — platform-wide config via env vars
|
||||
if google_wallet_service.is_configured or program.google_class_id:
|
||||
try:
|
||||
urls["google_wallet_url"] = google_wallet_service.get_save_url(db, card)
|
||||
except Exception as e: # noqa: EXC003
|
||||
@@ -131,8 +131,8 @@ class WalletService:
|
||||
|
||||
program = card.program
|
||||
|
||||
# Create Google Wallet object
|
||||
if program.google_issuer_id:
|
||||
# Create Google Wallet object — platform-wide config via env vars
|
||||
if google_wallet_service.is_configured:
|
||||
try:
|
||||
google_wallet_service.create_object(db, card)
|
||||
results["google_wallet"] = True
|
||||
|
||||
@@ -12,6 +12,9 @@ function customerLoyaltyDashboard() {
|
||||
transactions: [],
|
||||
locations: [],
|
||||
|
||||
// Wallet
|
||||
walletUrls: { google_wallet_url: null, apple_wallet_url: null },
|
||||
|
||||
// UI state
|
||||
loading: false,
|
||||
showBarcode: false,
|
||||
@@ -43,6 +46,7 @@ function customerLoyaltyDashboard() {
|
||||
this.program = response.program;
|
||||
this.rewards = response.program?.points_rewards || [];
|
||||
this.locations = response.locations || [];
|
||||
this.walletUrls = response.wallet_urls || { google_wallet_url: null, apple_wallet_url: null };
|
||||
console.log('Loyalty card loaded:', this.card?.card_number);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -203,14 +203,20 @@
|
||||
|
||||
<!-- Wallet Buttons -->
|
||||
<div class="space-y-2 mb-4">
|
||||
<button class="w-full flex items-center justify-center px-4 py-3 bg-black text-white rounded-lg hover:bg-gray-800 transition-colors">
|
||||
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
|
||||
Add to Apple Wallet
|
||||
</button>
|
||||
<button class="w-full flex items-center justify-center px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
|
||||
Add to Google Wallet
|
||||
</button>
|
||||
<template x-if="walletUrls.apple_wallet_url">
|
||||
<a :href="walletUrls.apple_wallet_url" target="_blank" rel="noopener"
|
||||
class="w-full flex items-center justify-center px-4 py-3 bg-black text-white rounded-lg hover:bg-gray-800 transition-colors">
|
||||
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
|
||||
Add to Apple Wallet
|
||||
</a>
|
||||
</template>
|
||||
<template x-if="walletUrls.google_wallet_url">
|
||||
<a :href="walletUrls.google_wallet_url" target="_blank" rel="noopener"
|
||||
class="w-full flex items-center justify-center px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
|
||||
Add to Google Wallet
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button @click="showBarcode = false"
|
||||
|
||||
@@ -29,14 +29,20 @@
|
||||
Save your card to your phone for easy access:
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
<button class="w-full flex items-center justify-center px-4 py-3 bg-black text-white rounded-lg hover:bg-gray-800 transition-colors">
|
||||
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
|
||||
Add to Apple Wallet
|
||||
</button>
|
||||
<button class="w-full flex items-center justify-center px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
|
||||
Add to Google Wallet
|
||||
</button>
|
||||
<template x-if="walletUrls.apple_wallet_url">
|
||||
<a :href="walletUrls.apple_wallet_url" target="_blank" rel="noopener"
|
||||
class="w-full flex items-center justify-center px-4 py-3 bg-black text-white rounded-lg hover:bg-gray-800 transition-colors">
|
||||
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
|
||||
Add to Apple Wallet
|
||||
</a>
|
||||
</template>
|
||||
<template x-if="walletUrls.google_wallet_url">
|
||||
<a :href="walletUrls.google_wallet_url" target="_blank" rel="noopener"
|
||||
class="w-full flex items-center justify-center px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
|
||||
Add to Google Wallet
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,7 +86,19 @@
|
||||
<script>
|
||||
function customerLoyaltyEnrollSuccess() {
|
||||
return {
|
||||
...data()
|
||||
...data(),
|
||||
walletUrls: { google_wallet_url: null, apple_wallet_url: null },
|
||||
async init() {
|
||||
try {
|
||||
const response = await apiClient.get('/storefront/loyalty/card');
|
||||
if (response && response.wallet_urls) {
|
||||
this.walletUrls = response.wallet_urls;
|
||||
}
|
||||
} catch (e) {
|
||||
// Customer may not be authenticated (public enrollment)
|
||||
console.log('Could not load wallet URLs:', e.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user