diff --git a/.gitignore b/.gitignore
index 7f9a76b8..b988a066 100644
--- a/.gitignore
+++ b/.gitignore
@@ -168,6 +168,11 @@ deployment-local/
secrets/
credentials/
+# Google Cloud service account keys
+*-service-account.json
+google-wallet-sa.json
+orion-*.json
+
# Alembic
# Note: Keep alembic/versions/ tracked for migrations
# alembic/versions/*.pyc is already covered by __pycache__
diff --git a/app/core/config.py b/app/core/config.py
index c5be52e8..8f592181 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -217,6 +217,12 @@ class Settings(BaseSettings):
# =============================================================================
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"}
diff --git a/app/modules/loyalty/routes/api/storefront.py b/app/modules/loyalty/routes/api/storefront.py
index 0ba0bfcc..0268c935 100644
--- a/app/modules/loyalty/routes/api/storefront.py
+++ b/app/modules/loyalty/routes/api/storefront.py
@@ -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,
}
diff --git a/app/modules/loyalty/services/card_service.py b/app/modules/loyalty/services/card_service.py
index 132bd73a..f823a8dc 100644
--- a/app/modules/loyalty/services/card_service.py
+++ b/app/modules/loyalty/services/card_service.py
@@ -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)"
diff --git a/app/modules/loyalty/services/points_service.py b/app/modules/loyalty/services/points_service.py
index 2f819768..f27e6edd 100644
--- a/app/modules/loyalty/services/points_service.py
+++ b/app/modules/loyalty/services/points_service.py
@@ -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})"
diff --git a/app/modules/loyalty/services/stamp_service.py b/app/modules/loyalty/services/stamp_service.py
index 946d2b02..3b908d5d 100644
--- a/app/modules/loyalty/services/stamp_service.py
+++ b/app/modules/loyalty/services/stamp_service.py
@@ -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})"
diff --git a/app/modules/loyalty/services/wallet_service.py b/app/modules/loyalty/services/wallet_service.py
index 4cbc85a7..b36840b9 100644
--- a/app/modules/loyalty/services/wallet_service.py
+++ b/app/modules/loyalty/services/wallet_service.py
@@ -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
diff --git a/app/modules/loyalty/static/storefront/js/loyalty-dashboard.js b/app/modules/loyalty/static/storefront/js/loyalty-dashboard.js
index c6c71534..1b5441ea 100644
--- a/app/modules/loyalty/static/storefront/js/loyalty-dashboard.js
+++ b/app/modules/loyalty/static/storefront/js/loyalty-dashboard.js
@@ -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) {
diff --git a/app/modules/loyalty/templates/loyalty/storefront/dashboard.html b/app/modules/loyalty/templates/loyalty/storefront/dashboard.html
index 71060b32..064974a8 100644
--- a/app/modules/loyalty/templates/loyalty/storefront/dashboard.html
+++ b/app/modules/loyalty/templates/loyalty/storefront/dashboard.html
@@ -203,14 +203,20 @@