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 @@
- - + +
- + + @@ -80,7 +86,19 @@ diff --git a/docs/deployment/hetzner-server-setup.md b/docs/deployment/hetzner-server-setup.md index 04357c96..c72f31ef 100644 --- a/docs/deployment/hetzner-server-setup.md +++ b/docs/deployment/hetzner-server-setup.md @@ -112,13 +112,19 @@ Complete step-by-step guide for deploying Orion on a Hetzner Cloud VPS. - Service account JSON key generated - 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_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:** - [ ] Upload service account JSON to Hetzner server - [ ] 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 - - [ ] Wire "Add to Google Wallet" button into storefront enrollment success page - [ ] Submit for Google production approval when ready - [ ] Apple Wallet setup (APNs push, certificates, pass images) @@ -1872,7 +1878,21 @@ Restart the application: 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: @@ -1880,12 +1900,11 @@ Check the API health and wallet service status: # Check the app logs for wallet service initialization docker compose --profile full logs api | grep -i "wallet\|loyalty" -# Test via API — create a program and enroll a customer, then check the response -# for google_object_id and google Wallet URL fields +# Test via API — enroll a customer and check the response for wallet URLs 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: @@ -1895,20 +1914,21 @@ Google provides a **demo mode** — passes work in test without full production **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) -3. The API returns a Google Wallet save URL -4. 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 + - The system automatically creates a Google Wallet `LoyaltyClass` (for the program) and `LoyaltyObject` (for the card) +3. Open the storefront loyalty dashboard — the "Add to Google Wallet" button appears +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: ```bash -# In your local .env (or export directly) -export LOYALTY_GOOGLE_ISSUER_ID=3388000000023089598 -export LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON=/path/to/orion-488322-xxxxx.json +# In your local .env +LOYALTY_GOOGLE_ISSUER_ID=3388000000023089598 +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. @@ -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] `LOYALTY_GOOGLE_ISSUER_ID` set in env -- [ ] Start the app locally: `uvicorn app.main:app --reload` -- [ ] Create a loyalty program → verify `google_class_id` is set -- [ ] Enroll a customer → verify `google_object_id` is set -- [ ] Call `get_save_url()` → open the URL on Android to add pass -- [ ] Add stamps → verify pass updates in Google Wallet +- [ ] Start the app locally: `python3 -m uvicorn main:app --reload` +- [ ] Enroll a customer → check logs for "Created Google Wallet class" and "Created Google Wallet object" +- [ ] Open storefront dashboard → "Add to Google Wallet" button should appear +- [ ] Open the wallet URL on Android → pass added to 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 │ -│ creates │ │ │ │ │ -│ program │ │ create_class │ │ POST /loyaltyClass │ +│ sets issuer │ │ │ │ │ +│ ID in UI │ │ │ │ │ └─────────────┘ └──────────────┘ └─────────────────────┘ ┌─────────────┐ ┌──────────────┐ ┌─────────────────────┐ │ Customer │────▶│ Orion API │────▶│ Google Wallet API │ │ enrolls │ │ │ │ │ +│ │ │create_class +│ │ POST /loyaltyClass │ │ │ │create_object │ │ POST /loyaltyObject │ │ │◀────│ 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 │ -│ stamp │ │ │ │ │ +│ stamp/pts │ │ │ │ │ │ │ │update_object │ │ PATCH /loyaltyObject│ └─────────────┘ └──────────────┘ └─────────────────────┘ Pass auto-updates on 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. -### 25.10 Next Steps +### 25.11 Next Steps After Google Wallet is verified working: -1. **Wire "Add to Google Wallet" button** into the storefront enrollment success page and card dashboard -2. **Submit for Google production approval** — required before non-test users can add passes -3. **Apple Wallet** — separate setup requiring Apple Developer account, APNs certificates, and pass signing certificates (see [Loyalty Module docs](../modules/loyalty.md#apple-wallet)) +1. **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)) --- diff --git a/docs/modules/loyalty.md b/docs/modules/loyalty.md index 5077a190..b7cd57a1 100644 --- a/docs/modules/loyalty.md +++ b/docs/modules/loyalty.md @@ -167,14 +167,19 @@ Maximum stamps per card per day (default: 5). ### 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 -2. Customer enrolls → Create `LoyaltyObject` via Google API -3. Stamp/points change → `PATCH` the object -4. Generate JWT for "Add to Wallet" button +All wallet operations are triggered automatically — no manual API calls needed: -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 diff --git a/docs/proposals/google-wallet-local-testing.md b/docs/proposals/google-wallet-local-testing.md new file mode 100644 index 00000000..413d0564 --- /dev/null +++ b/docs/proposals/google-wallet-local-testing.md @@ -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: + +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: +Created Google Wallet object: +Enrolled customer X in merchant Y loyalty program +``` + +### Option B — Customer self-enrollment (storefront) + +1. Go to: +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 = ; +``` + +Both `google_class_id` and `google_object_id` should be non-null. + +--- + +## Step 5: Test the storefront dashboard (wallet button) + +1. Go to: +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 (), add a stamp or earn points for the enrolled customer's card. + +Watch terminal for: + +``` +Updated Google Wallet object for card +``` + +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) | | +| Storefront | | +| Admin panel | |