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:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -168,6 +168,11 @@ deployment-local/
|
|||||||
secrets/
|
secrets/
|
||||||
credentials/
|
credentials/
|
||||||
|
|
||||||
|
# Google Cloud service account keys
|
||||||
|
*-service-account.json
|
||||||
|
google-wallet-sa.json
|
||||||
|
orion-*.json
|
||||||
|
|
||||||
# Alembic
|
# Alembic
|
||||||
# Note: Keep alembic/versions/ tracked for migrations
|
# Note: Keep alembic/versions/ tracked for migrations
|
||||||
# alembic/versions/*.pyc is already covered by __pycache__
|
# alembic/versions/*.pyc is already covered by __pycache__
|
||||||
|
|||||||
@@ -217,6 +217,12 @@ class Settings(BaseSettings):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
cloudflare_enabled: bool = False # Set to True when using CloudFlare proxy
|
cloudflare_enabled: bool = False # Set to True when using CloudFlare proxy
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# GOOGLE WALLET (LOYALTY MODULE)
|
||||||
|
# =============================================================================
|
||||||
|
loyalty_google_issuer_id: str | None = None
|
||||||
|
loyalty_google_service_account_json: str | None = None # Path to service account JSON
|
||||||
|
|
||||||
model_config = {"env_file": ".env"}
|
model_config = {"env_file": ".env"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from app.modules.loyalty.schemas import (
|
|||||||
CardResponse,
|
CardResponse,
|
||||||
ProgramResponse,
|
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
|
from app.modules.tenancy.exceptions import StoreNotFoundException
|
||||||
|
|
||||||
storefront_router = APIRouter()
|
storefront_router = APIRouter()
|
||||||
@@ -89,8 +89,32 @@ def self_enroll(
|
|||||||
logger.info(f"Self-enrollment for customer {customer_id} at store {store.subdomain}")
|
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)
|
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.is_points_enabled = program.is_points_enabled
|
||||||
program_response.display_name = program.display_name
|
program_response.display_name = program.display_name
|
||||||
|
|
||||||
|
wallet_urls = wallet_service.get_add_to_wallet_urls(db, card)
|
||||||
|
|
||||||
return {
|
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,
|
"program": program_response,
|
||||||
"locations": [{"id": v.id, "name": v.name} for v in locations],
|
"locations": [{"id": v.id, "name": v.name} for v in locations],
|
||||||
|
"wallet_urls": wallet_urls,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -416,6 +416,12 @@ class CardService:
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(card)
|
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(
|
logger.info(
|
||||||
f"Enrolled customer {customer_id} in merchant {merchant_id} loyalty program "
|
f"Enrolled customer {customer_id} in merchant {merchant_id} loyalty program "
|
||||||
f"(card: {card.card_number}, bonus: {program.welcome_bonus_points} pts)"
|
f"(card: {card.card_number}, bonus: {program.welcome_bonus_points} pts)"
|
||||||
|
|||||||
@@ -169,6 +169,11 @@ class PointsService:
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(card)
|
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(
|
logger.info(
|
||||||
f"Added {points_earned} points to card {card.id} at store {store_id} "
|
f"Added {points_earned} points to card {card.id} at store {store_id} "
|
||||||
f"(purchase: €{purchase_euros:.2f}, balance: {card.points_balance})"
|
f"(purchase: €{purchase_euros:.2f}, balance: {card.points_balance})"
|
||||||
@@ -295,6 +300,11 @@ class PointsService:
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(card)
|
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(
|
logger.info(
|
||||||
f"Redeemed {points_required} points from card {card.id} at store {store_id} "
|
f"Redeemed {points_required} points from card {card.id} at store {store_id} "
|
||||||
f"(reward: {reward_name}, balance: {card.points_balance})"
|
f"(reward: {reward_name}, balance: {card.points_balance})"
|
||||||
@@ -437,6 +447,11 @@ class PointsService:
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(card)
|
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(
|
logger.info(
|
||||||
f"Voided {actual_voided} points from card {card.id} at store {store_id} "
|
f"Voided {actual_voided} points from card {card.id} at store {store_id} "
|
||||||
f"(balance: {card.points_balance})"
|
f"(balance: {card.points_balance})"
|
||||||
@@ -523,6 +538,11 @@ class PointsService:
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(card)
|
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(
|
logger.info(
|
||||||
f"Adjusted points for card {card.id} by {points_delta:+d} "
|
f"Adjusted points for card {card.id} by {points_delta:+d} "
|
||||||
f"(reason: {reason}, balance: {card.points_balance})"
|
f"(reason: {reason}, balance: {card.points_balance})"
|
||||||
|
|||||||
@@ -154,6 +154,11 @@ class StampService:
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(card)
|
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
|
stamps_today += 1
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -273,6 +278,11 @@ class StampService:
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(card)
|
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(
|
logger.info(
|
||||||
f"Redeemed stamps from card {card.id} at store {store_id} "
|
f"Redeemed stamps from card {card.id} at store {store_id} "
|
||||||
f"(reward: {program.stamps_reward_description}, "
|
f"(reward: {program.stamps_reward_description}, "
|
||||||
@@ -400,6 +410,11 @@ class StampService:
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(card)
|
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(
|
logger.info(
|
||||||
f"Voided {actual_voided} stamps from card {card.id} at store {store_id} "
|
f"Voided {actual_voided} stamps from card {card.id} at store {store_id} "
|
||||||
f"(balance: {card.stamp_count})"
|
f"(balance: {card.stamp_count})"
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ class WalletService:
|
|||||||
|
|
||||||
program = card.program
|
program = card.program
|
||||||
|
|
||||||
# Google Wallet
|
# Google Wallet — platform-wide config via env vars
|
||||||
if program.google_issuer_id or program.google_class_id:
|
if google_wallet_service.is_configured or program.google_class_id:
|
||||||
try:
|
try:
|
||||||
urls["google_wallet_url"] = google_wallet_service.get_save_url(db, card)
|
urls["google_wallet_url"] = google_wallet_service.get_save_url(db, card)
|
||||||
except Exception as e: # noqa: EXC003
|
except Exception as e: # noqa: EXC003
|
||||||
@@ -131,8 +131,8 @@ class WalletService:
|
|||||||
|
|
||||||
program = card.program
|
program = card.program
|
||||||
|
|
||||||
# Create Google Wallet object
|
# Create Google Wallet object — platform-wide config via env vars
|
||||||
if program.google_issuer_id:
|
if google_wallet_service.is_configured:
|
||||||
try:
|
try:
|
||||||
google_wallet_service.create_object(db, card)
|
google_wallet_service.create_object(db, card)
|
||||||
results["google_wallet"] = True
|
results["google_wallet"] = True
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ function customerLoyaltyDashboard() {
|
|||||||
transactions: [],
|
transactions: [],
|
||||||
locations: [],
|
locations: [],
|
||||||
|
|
||||||
|
// Wallet
|
||||||
|
walletUrls: { google_wallet_url: null, apple_wallet_url: null },
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
loading: false,
|
loading: false,
|
||||||
showBarcode: false,
|
showBarcode: false,
|
||||||
@@ -43,6 +46,7 @@ function customerLoyaltyDashboard() {
|
|||||||
this.program = response.program;
|
this.program = response.program;
|
||||||
this.rewards = response.program?.points_rewards || [];
|
this.rewards = response.program?.points_rewards || [];
|
||||||
this.locations = response.locations || [];
|
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);
|
console.log('Loyalty card loaded:', this.card?.card_number);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -203,14 +203,20 @@
|
|||||||
|
|
||||||
<!-- Wallet Buttons -->
|
<!-- Wallet Buttons -->
|
||||||
<div class="space-y-2 mb-4">
|
<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">
|
<template x-if="walletUrls.apple_wallet_url">
|
||||||
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
|
<a :href="walletUrls.apple_wallet_url" target="_blank" rel="noopener"
|
||||||
Add to Apple Wallet
|
class="w-full flex items-center justify-center px-4 py-3 bg-black text-white rounded-lg hover:bg-gray-800 transition-colors">
|
||||||
</button>
|
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
|
||||||
<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">
|
Add to Apple Wallet
|
||||||
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
|
</a>
|
||||||
Add to Google Wallet
|
</template>
|
||||||
</button>
|
<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>
|
||||||
|
|
||||||
<button @click="showBarcode = false"
|
<button @click="showBarcode = false"
|
||||||
|
|||||||
@@ -29,14 +29,20 @@
|
|||||||
Save your card to your phone for easy access:
|
Save your card to your phone for easy access:
|
||||||
</p>
|
</p>
|
||||||
<div class="space-y-2">
|
<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">
|
<template x-if="walletUrls.apple_wallet_url">
|
||||||
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
|
<a :href="walletUrls.apple_wallet_url" target="_blank" rel="noopener"
|
||||||
Add to Apple Wallet
|
class="w-full flex items-center justify-center px-4 py-3 bg-black text-white rounded-lg hover:bg-gray-800 transition-colors">
|
||||||
</button>
|
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
|
||||||
<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">
|
Add to Apple Wallet
|
||||||
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
|
</a>
|
||||||
Add to Google Wallet
|
</template>
|
||||||
</button>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,7 +86,19 @@
|
|||||||
<script>
|
<script>
|
||||||
function customerLoyaltyEnrollSuccess() {
|
function customerLoyaltyEnrollSuccess() {
|
||||||
return {
|
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>
|
</script>
|
||||||
|
|||||||
@@ -112,13 +112,19 @@ Complete step-by-step guide for deploying Orion on a Hetzner Cloud VPS.
|
|||||||
- Service account JSON key generated
|
- Service account JSON key generated
|
||||||
- Dependencies added to `requirements.txt`: `google-auth>=2.0.0`, `PyJWT>=2.0.0` (commit `d36783a`)
|
- Dependencies added to `requirements.txt`: `google-auth>=2.0.0`, `PyJWT>=2.0.0` (commit `d36783a`)
|
||||||
- Loyalty env vars added to `.env.example` and `docs/deployment/environment.md`
|
- Loyalty env vars added to `.env.example` and `docs/deployment/environment.md`
|
||||||
|
- `LOYALTY_GOOGLE_ISSUER_ID` and `LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON` added to `app/core/config.py` Settings class
|
||||||
|
- **End-to-end integration wired:**
|
||||||
|
- Enrollment auto-creates Google Wallet class + object (`card_service` → `wallet_service.create_wallet_objects`)
|
||||||
|
- Stamp/points operations auto-sync to Google Wallet (`stamp_service`/`points_service` → `wallet_service.sync_card_to_wallets`)
|
||||||
|
- Storefront API returns wallet URLs (`GET /loyalty/card`, `POST /loyalty/enroll`)
|
||||||
|
- "Add to Google Wallet" button wired in storefront dashboard and enrollment success page (Alpine.js conditional rendering)
|
||||||
|
- Google Wallet is a platform-wide config (env vars only) — merchants don't need to configure anything
|
||||||
|
|
||||||
**Next steps:**
|
**Next steps:**
|
||||||
|
|
||||||
- [ ] Upload service account JSON to Hetzner server
|
- [ ] Upload service account JSON to Hetzner server
|
||||||
- [ ] Set `LOYALTY_GOOGLE_ISSUER_ID` and `LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON` in production `.env`
|
- [ ] Set `LOYALTY_GOOGLE_ISSUER_ID` and `LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON` in production `.env`
|
||||||
- [ ] Restart app and test end-to-end: enroll → add pass → stamp → verify pass updates
|
- [ ] Restart app and test end-to-end: enroll → add pass → stamp → verify pass updates
|
||||||
- [ ] Wire "Add to Google Wallet" button into storefront enrollment success page
|
|
||||||
- [ ] Submit for Google production approval when ready
|
- [ ] Submit for Google production approval when ready
|
||||||
- [ ] Apple Wallet setup (APNs push, certificates, pass images)
|
- [ ] Apple Wallet setup (APNs push, certificates, pass images)
|
||||||
|
|
||||||
@@ -1872,7 +1878,21 @@ Restart the application:
|
|||||||
docker compose --profile full up -d --build
|
docker compose --profile full up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
### 25.6 Verify Configuration
|
### 25.6 Platform-Level Configuration
|
||||||
|
|
||||||
|
Google Wallet is a **platform-wide setting** — all merchants on the platform share the same Issuer ID and service account. Merchants don't need to configure anything; wallet integration activates automatically when the env vars are set.
|
||||||
|
|
||||||
|
The two required env vars:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In production .env
|
||||||
|
LOYALTY_GOOGLE_ISSUER_ID=3388000000023089598
|
||||||
|
LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=/app/google-wallet-sa.json
|
||||||
|
```
|
||||||
|
|
||||||
|
When both are set, every loyalty program on the platform automatically gets Google Wallet support: enrollment creates wallet passes, stamp/points operations sync to passes, and the storefront shows "Add to Google Wallet" buttons.
|
||||||
|
|
||||||
|
### 25.7 Verify Configuration
|
||||||
|
|
||||||
Check the API health and wallet service status:
|
Check the API health and wallet service status:
|
||||||
|
|
||||||
@@ -1880,12 +1900,11 @@ Check the API health and wallet service status:
|
|||||||
# Check the app logs for wallet service initialization
|
# Check the app logs for wallet service initialization
|
||||||
docker compose --profile full logs api | grep -i "wallet\|loyalty"
|
docker compose --profile full logs api | grep -i "wallet\|loyalty"
|
||||||
|
|
||||||
# Test via API — create a program and enroll a customer, then check the response
|
# Test via API — enroll a customer and check the response for wallet URLs
|
||||||
# for google_object_id and google Wallet URL fields
|
|
||||||
curl -s https://api.wizard.lu/health | python3 -m json.tool
|
curl -s https://api.wizard.lu/health | python3 -m json.tool
|
||||||
```
|
```
|
||||||
|
|
||||||
### 25.7 Testing Google Wallet Passes
|
### 25.8 Testing Google Wallet Passes
|
||||||
|
|
||||||
Google provides a **demo mode** — passes work in test without full production approval:
|
Google provides a **demo mode** — passes work in test without full production approval:
|
||||||
|
|
||||||
@@ -1895,20 +1914,21 @@ Google provides a **demo mode** — passes work in test without full production
|
|||||||
|
|
||||||
**End-to-end test flow:**
|
**End-to-end test flow:**
|
||||||
|
|
||||||
1. Create a loyalty program via the store panel
|
1. Create a loyalty program via the store panel and set the Google Wallet Issuer ID in Settings → Digital Wallet
|
||||||
2. Enroll a customer (via store or storefront self-enrollment)
|
2. Enroll a customer (via store or storefront self-enrollment)
|
||||||
3. The API returns a Google Wallet save URL
|
- The system automatically creates a Google Wallet `LoyaltyClass` (for the program) and `LoyaltyObject` (for the card)
|
||||||
4. Open the URL on an Android device — the pass is added to Google Wallet
|
3. Open the storefront loyalty dashboard — the "Add to Google Wallet" button appears
|
||||||
5. Add a stamp or points — the pass in Google Wallet auto-updates
|
4. Click the button (or open the URL on an Android device) — the pass is added to Google Wallet
|
||||||
|
5. Add a stamp or points — the pass in Google Wallet auto-updates (no push needed, Google syncs)
|
||||||
|
|
||||||
### 25.8 Local Development Setup
|
### 25.9 Local Development Setup
|
||||||
|
|
||||||
You can test the full Google Wallet integration from your local machine:
|
You can test the full Google Wallet integration from your local machine:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# In your local .env (or export directly)
|
# In your local .env
|
||||||
export LOYALTY_GOOGLE_ISSUER_ID=3388000000023089598
|
LOYALTY_GOOGLE_ISSUER_ID=3388000000023089598
|
||||||
export LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=/path/to/orion-488322-xxxxx.json
|
LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=/path/to/orion-488322-xxxxx.json
|
||||||
```
|
```
|
||||||
|
|
||||||
The `GoogleWalletService` calls Google's REST API directly over HTTPS — no special network configuration needed. The same service account JSON works on both local and server environments.
|
The `GoogleWalletService` calls Google's REST API directly over HTTPS — no special network configuration needed. The same service account JSON works on both local and server environments.
|
||||||
@@ -1917,24 +1937,27 @@ The `GoogleWalletService` calls Google's REST API directly over HTTPS — no spe
|
|||||||
|
|
||||||
- [x] Service account JSON downloaded and path set in env
|
- [x] Service account JSON downloaded and path set in env
|
||||||
- [x] `LOYALTY_GOOGLE_ISSUER_ID` set in env
|
- [x] `LOYALTY_GOOGLE_ISSUER_ID` set in env
|
||||||
- [ ] Start the app locally: `uvicorn app.main:app --reload`
|
- [ ] Start the app locally: `python3 -m uvicorn main:app --reload`
|
||||||
- [ ] Create a loyalty program → verify `google_class_id` is set
|
- [ ] Enroll a customer → check logs for "Created Google Wallet class" and "Created Google Wallet object"
|
||||||
- [ ] Enroll a customer → verify `google_object_id` is set
|
- [ ] Open storefront dashboard → "Add to Google Wallet" button should appear
|
||||||
- [ ] Call `get_save_url()` → open the URL on Android to add pass
|
- [ ] Open the wallet URL on Android → pass added to Google Wallet
|
||||||
- [ ] Add stamps → verify pass updates in Google Wallet
|
- [ ] Add stamps → check logs for "Updated Google Wallet object", verify pass updates
|
||||||
|
|
||||||
### 25.9 How It Works (Architecture)
|
### 25.10 How It Works (Architecture)
|
||||||
|
|
||||||
|
The integration is fully automatic — no manual API calls needed after initial setup.
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐
|
┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐
|
||||||
│ Merchant │────▶│ Orion API │────▶│ Google Wallet API │
|
│ Merchant │────▶│ Orion API │────▶│ Google Wallet API │
|
||||||
│ creates │ │ │ │ │
|
│ sets issuer │ │ │ │ │
|
||||||
│ program │ │ create_class │ │ POST /loyaltyClass │
|
│ ID in UI │ │ │ │ │
|
||||||
└─────────────┘ └──────────────┘ └─────────────────────┘
|
└─────────────┘ └──────────────┘ └─────────────────────┘
|
||||||
|
|
||||||
┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐
|
┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐
|
||||||
│ Customer │────▶│ Orion API │────▶│ Google Wallet API │
|
│ Customer │────▶│ Orion API │────▶│ Google Wallet API │
|
||||||
│ enrolls │ │ │ │ │
|
│ enrolls │ │ │ │ │
|
||||||
|
│ │ │create_class +│ │ POST /loyaltyClass │
|
||||||
│ │ │create_object │ │ POST /loyaltyObject │
|
│ │ │create_object │ │ POST /loyaltyObject │
|
||||||
│ │◀────│ save_url │ │ │
|
│ │◀────│ save_url │ │ │
|
||||||
│ │ └──────────────┘ └─────────────────────┘
|
│ │ └──────────────┘ └─────────────────────┘
|
||||||
@@ -1944,22 +1967,30 @@ The `GoogleWalletService` calls Google's REST API directly over HTTPS — no spe
|
|||||||
|
|
||||||
┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐
|
┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐
|
||||||
│ Staff adds │────▶│ Orion API │────▶│ Google Wallet API │
|
│ Staff adds │────▶│ Orion API │────▶│ Google Wallet API │
|
||||||
│ stamp │ │ │ │ │
|
│ stamp/pts │ │ │ │ │
|
||||||
│ │ │update_object │ │ PATCH /loyaltyObject│
|
│ │ │update_object │ │ PATCH /loyaltyObject│
|
||||||
└─────────────┘ └──────────────┘ └─────────────────────┘
|
└─────────────┘ └──────────────┘ └─────────────────────┘
|
||||||
Pass auto-updates on
|
Pass auto-updates on
|
||||||
customer's phone
|
customer's phone
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Automatic triggers:**
|
||||||
|
|
||||||
|
| Event | Wallet Action | Service Call |
|
||||||
|
|-------|---------------|--------------|
|
||||||
|
| Customer enrolls | Create class (if first) + create object | `wallet_service.create_wallet_objects()` |
|
||||||
|
| Stamp added/redeemed/voided | Update object with new balance | `wallet_service.sync_card_to_wallets()` |
|
||||||
|
| Points earned/redeemed/voided/adjusted | Update object with new balance | `wallet_service.sync_card_to_wallets()` |
|
||||||
|
| Customer opens dashboard | Generate save URL (JWT, 1h expiry) | `wallet_service.get_add_to_wallet_urls()` |
|
||||||
|
|
||||||
No push notifications needed — Google syncs object changes automatically.
|
No push notifications needed — Google syncs object changes automatically.
|
||||||
|
|
||||||
### 25.10 Next Steps
|
### 25.11 Next Steps
|
||||||
|
|
||||||
After Google Wallet is verified working:
|
After Google Wallet is verified working:
|
||||||
|
|
||||||
1. **Wire "Add to Google Wallet" button** into the storefront enrollment success page and card dashboard
|
1. **Submit for Google production approval** — required before non-test users can add passes
|
||||||
2. **Submit for Google production approval** — required before non-test users can add passes
|
2. **Apple Wallet** — separate setup requiring Apple Developer account, APNs certificates, and pass signing certificates (see [Loyalty Module docs](../modules/loyalty.md#apple-wallet))
|
||||||
3. **Apple Wallet** — separate setup requiring Apple Developer account, APNs certificates, and pass signing certificates (see [Loyalty Module docs](../modules/loyalty.md#apple-wallet))
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -167,14 +167,19 @@ Maximum stamps per card per day (default: 5).
|
|||||||
|
|
||||||
### Google Wallet
|
### Google Wallet
|
||||||
|
|
||||||
Architecture: **Server-side storage with API updates**
|
Architecture: **Server-side storage with automatic API updates**
|
||||||
|
|
||||||
1. Program created → Create `LoyaltyClass` via Google API
|
All wallet operations are triggered automatically — no manual API calls needed:
|
||||||
2. Customer enrolls → Create `LoyaltyObject` via Google API
|
|
||||||
3. Stamp/points change → `PATCH` the object
|
|
||||||
4. Generate JWT for "Add to Wallet" button
|
|
||||||
|
|
||||||
No device registration needed - Google syncs automatically.
|
| Event | Wallet Action | Trigger |
|
||||||
|
|-------|---------------|---------|
|
||||||
|
| Customer enrolls | Create `LoyaltyClass` (first time) + `LoyaltyObject` | `card_service.enroll_customer()` → `wallet_service.create_wallet_objects()` |
|
||||||
|
| Stamp/points change | `PATCH` the object with new balance | `stamp_service`/`points_service` → `wallet_service.sync_card_to_wallets()` |
|
||||||
|
| Customer views dashboard | Generate JWT "Add to Wallet" URL (1h expiry) | `GET /storefront/loyalty/card` → `wallet_service.get_add_to_wallet_urls()` |
|
||||||
|
|
||||||
|
**Setup:** Configure `LOYALTY_GOOGLE_ISSUER_ID` and `LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON` env vars. This is a platform-wide setting — all merchants automatically get Google Wallet support. See [Google Wallet Setup](../deployment/hetzner-server-setup.md#step-25-google-wallet-integration) for full instructions.
|
||||||
|
|
||||||
|
No device registration needed — Google syncs automatically.
|
||||||
|
|
||||||
### Apple Wallet
|
### Apple Wallet
|
||||||
|
|
||||||
|
|||||||
140
docs/proposals/google-wallet-local-testing.md
Normal file
140
docs/proposals/google-wallet-local-testing.md
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# Google Wallet Local Testing — Step-by-Step
|
||||||
|
|
||||||
|
All code wiring is complete. This guide walks through end-to-end local testing.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
`.env` must have:
|
||||||
|
|
||||||
|
```
|
||||||
|
LOYALTY_GOOGLE_ISSUER_ID=3388000000023089598
|
||||||
|
LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=/home/samir/Documents/PycharmProjects/letzshop-product-import/orion-488322-2232195cbb62.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Start the server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
No startup errors expected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1: Log into the FASHIONHUB store panel
|
||||||
|
|
||||||
|
URL: <http://localhost:8000/platforms/loyalty/store/FASHIONHUB/login>
|
||||||
|
|
||||||
|
Log in with store credentials.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2: Verify a loyalty program exists
|
||||||
|
|
||||||
|
In the store dashboard, check that an active loyalty program exists with stamps or points enabled.
|
||||||
|
|
||||||
|
If none exists, create one from Settings > Loyalty:
|
||||||
|
|
||||||
|
- **Name**: e.g. "Fashion Rewards"
|
||||||
|
- **Stamps target** or **Points mode**: enable at least one
|
||||||
|
- **Welcome bonus points**: optional, set to e.g. 50 to test points on enrollment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3: Enroll a test customer
|
||||||
|
|
||||||
|
Two options:
|
||||||
|
|
||||||
|
### Option A — Staff enrollment (store panel)
|
||||||
|
|
||||||
|
In the store dashboard, go to Loyalty, use "Enroll Customer" by email or customer ID.
|
||||||
|
|
||||||
|
Watch terminal for:
|
||||||
|
|
||||||
|
```
|
||||||
|
Created Google Wallet class: <class_id>
|
||||||
|
Created Google Wallet object: <object_id>
|
||||||
|
Enrolled customer X in merchant Y loyalty program
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option B — Customer self-enrollment (storefront)
|
||||||
|
|
||||||
|
1. Go to: <http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/>
|
||||||
|
2. Log in as a customer (or create an account)
|
||||||
|
3. Navigate to Loyalty > "Join Now"
|
||||||
|
4. After enrollment, you land on the **enroll-success** page
|
||||||
|
5. Watch terminal for the same Google Wallet creation logs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 4: Verify DB records
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Program should have google_class_id populated
|
||||||
|
SELECT id, name, google_issuer_id, google_class_id FROM loyalty_programs;
|
||||||
|
|
||||||
|
-- Card should have google_object_id populated
|
||||||
|
SELECT id, card_number, google_object_id, google_object_jwt
|
||||||
|
FROM loyalty_cards WHERE customer_id = <your_customer_id>;
|
||||||
|
```
|
||||||
|
|
||||||
|
Both `google_class_id` and `google_object_id` should be non-null.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 5: Test the storefront dashboard (wallet button)
|
||||||
|
|
||||||
|
1. Go to: <http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/>
|
||||||
|
2. Log in as the enrolled customer
|
||||||
|
3. Go to **Account > My Loyalty**
|
||||||
|
4. Click **"Show Card"** — a modal appears
|
||||||
|
5. The **"Add to Google Wallet"** button (blue) should be visible
|
||||||
|
6. Click it — opens `https://pay.google.com/gp/v/save/...` in a new tab
|
||||||
|
|
||||||
|
If the button doesn't appear, check browser devtools > Network > `GET /storefront/loyalty/card` response and look at `wallet_urls.google_wallet_url`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 6: Test stamp/points sync
|
||||||
|
|
||||||
|
From the store panel (<http://localhost:8000/platforms/loyalty/store/FASHIONHUB/>), add a stamp or earn points for the enrolled customer's card.
|
||||||
|
|
||||||
|
Watch terminal for:
|
||||||
|
|
||||||
|
```
|
||||||
|
Updated Google Wallet object for card <card_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
This confirms the wallet pass updates on the customer's phone in real time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 7: Verify the save URL works
|
||||||
|
|
||||||
|
The "Add to Google Wallet" URL is JWT-signed. Behaviour:
|
||||||
|
|
||||||
|
- **Demo/test mode** (before Google approves your issuer): preview page with unverified issuer warning — this is normal
|
||||||
|
- **Android**: pass gets added to Google Wallet app
|
||||||
|
- **Desktop**: Google shows a "Send to phone" option
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Check |
|
||||||
|
|---------|-------|
|
||||||
|
| No wallet logs on enrollment | Verify `LOYALTY_GOOGLE_ISSUER_ID` and `LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON` are set in `.env` |
|
||||||
|
| "Google Wallet not configured" in logs | Service account JSON file path is wrong or file is unreadable |
|
||||||
|
| Button doesn't appear on dashboard | Check `GET /storefront/loyalty/card` response in devtools — `wallet_urls` should have a URL |
|
||||||
|
| 403 from Google API | Service account doesn't have Wallet API permissions, or issuer ID mismatch |
|
||||||
|
| JWT URL opens but shows error | Issuer account may not be approved yet — normal for testing |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key URLs
|
||||||
|
|
||||||
|
| Panel | URL |
|
||||||
|
|-------|-----|
|
||||||
|
| Store panel (FASHIONHUB) | <http://localhost:8000/platforms/loyalty/store/FASHIONHUB/login> |
|
||||||
|
| Storefront | <http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/> |
|
||||||
|
| Admin panel | <http://localhost:8000/admin/login> |
|
||||||
Reference in New Issue
Block a user