From 8c8975239ac7f5fcfee7670bd9db9f0b003164a8 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Wed, 11 Mar 2026 17:32:55 +0100 Subject: [PATCH] feat(loyalty): fix Google Wallet integration and improve enrollment flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix Google Wallet class creation: add required issuerName field (merchant name), programLogo with default logo fallback, hexBackgroundColor default - Add default loyalty logo assets (200px + 512px) for programs without custom logos - Smart retry: skip retries on 400/401/403/404 client errors (not transient) - Fix enrollment success page: use sessionStorage for wallet URLs instead of authenticated API call (self-enrolled customers have no session) - Hide wallet section on success page when no wallet URLs available - Wire up T&C modal on enrollment page with program.terms_text - Add startup validation for Google/Apple Wallet configs in lifespan - Add admin wallet status dashboard endpoint and UI (moved to service layer) - Fix Apple Wallet push notifications with real APNs HTTP/2 implementation - Fix docs: correct enrollment URLs (port, path segments, /v1 prefix) - Fix test assertion: !loyalty-enroll! → !enrollment! Co-Authored-By: Claude Opus 4.6 --- app/core/config.py | 11 + app/core/lifespan.py | 69 ++++ app/modules/loyalty/docs/user-journeys.md | 322 +++++++++--------- app/modules/loyalty/routes/api/admin.py | 14 + .../loyalty/services/apple_wallet_service.py | 101 +++++- .../loyalty/services/google_wallet_service.py | 306 +++++++++++++---- .../loyalty/services/program_service.py | 56 +++ .../static/admin/js/loyalty-analytics.js | 20 ++ .../static/shared/img/default-logo-200.png | Bin 0 -> 11670 bytes .../static/shared/img/default-logo-512.png | Bin 0 -> 13793 bytes .../static/storefront/js/loyalty-enroll.js | 10 +- .../templates/loyalty/admin/analytics.html | 103 ++++++ .../loyalty/storefront/enroll-success.html | 14 +- .../templates/loyalty/storefront/enroll.html | 39 ++- .../loyalty/tests/unit/test_card_service.py | 2 +- 15 files changed, 828 insertions(+), 239 deletions(-) create mode 100644 app/modules/loyalty/static/shared/img/default-logo-200.png create mode 100644 app/modules/loyalty/static/shared/img/default-logo-512.png diff --git a/app/core/config.py b/app/core/config.py index 8f592181..6eebafc1 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -222,6 +222,17 @@ class Settings(BaseSettings): # ============================================================================= loyalty_google_issuer_id: str | None = None loyalty_google_service_account_json: str | None = None # Path to service account JSON + loyalty_google_wallet_origins: list[str] = [] # Allowed origins for save-to-wallet JWT + loyalty_default_logo_url: str = "https://rewardflow.lu/static/modules/loyalty/shared/img/default-logo-200.png" + + # ============================================================================= + # APPLE WALLET (LOYALTY MODULE) + # ============================================================================= + loyalty_apple_pass_type_id: str | None = None + loyalty_apple_team_id: str | None = None + loyalty_apple_wwdr_cert_path: str | None = None + loyalty_apple_signer_cert_path: str | None = None + loyalty_apple_signer_key_path: str | None = None model_config = {"env_file": ".env"} diff --git a/app/core/lifespan.py b/app/core/lifespan.py index 1e83c1ea..b5acf9b0 100644 --- a/app/core/lifespan.py +++ b/app/core/lifespan.py @@ -44,6 +44,9 @@ async def lifespan(app: FastAPI): grafana_url=settings.grafana_url, ) + # Validate wallet configurations + _validate_wallet_config() + logger.info("[OK] Application startup completed") yield @@ -53,6 +56,72 @@ async def lifespan(app: FastAPI): shutdown_observability() +def _validate_wallet_config(): + """Validate Google/Apple Wallet configuration at startup.""" + try: + from app.modules.loyalty.services.google_wallet_service import ( + google_wallet_service, + ) + + result = google_wallet_service.validate_config() + if result["configured"]: + if result["credentials_valid"]: + logger.info( + "[OK] Google Wallet configured (issuer: %s, email: %s)", + result["issuer_id"], + result.get("service_account_email", "unknown"), + ) + else: + for err in result["errors"]: + logger.error("[FAIL] Google Wallet config error: %s", err) + else: + logger.info("[--] Google Wallet not configured (optional)") + + # Apple Wallet config check + if settings.loyalty_apple_pass_type_id: + import os + + missing = [] + for field in [ + "loyalty_apple_team_id", + "loyalty_apple_wwdr_cert_path", + "loyalty_apple_signer_cert_path", + "loyalty_apple_signer_key_path", + ]: + val = getattr(settings, field, None) + if not val: + missing.append(field) + elif field.endswith("_path") and not os.path.isfile(val): + logger.error( + "[FAIL] Apple Wallet file not found: %s = %s", + field, + val, + ) + + if missing: + logger.error( + "[FAIL] Apple Wallet missing config: %s", + ", ".join(missing), + ) + elif not any( + not os.path.isfile(getattr(settings, f, "") or "") + for f in [ + "loyalty_apple_wwdr_cert_path", + "loyalty_apple_signer_cert_path", + "loyalty_apple_signer_key_path", + ] + ): + logger.info( + "[OK] Apple Wallet configured (pass type: %s)", + settings.loyalty_apple_pass_type_id, + ) + else: + logger.info("[--] Apple Wallet not configured (optional)") + + except Exception as exc: # noqa: BLE001 + logger.warning("Wallet config validation skipped: %s", exc) + + # === NEW HELPER FUNCTION === def check_database_ready(): """Check if database is ready (migrations have been run).""" diff --git a/app/modules/loyalty/docs/user-journeys.md b/app/modules/loyalty/docs/user-journeys.md index 4dd75478..9adc0e03 100644 --- a/app/modules/loyalty/docs/user-journeys.md +++ b/app/modules/loyalty/docs/user-journeys.md @@ -62,9 +62,9 @@ --- -## Dev URLs (localhost:9999) +## Dev URLs (localhost:8000) -The dev server uses path-based platform routing: `http://localhost:9999/platforms/loyalty/...` +The dev server uses path-based platform routing: `http://localhost:8000/platforms/loyalty/...` ### 1. Platform Admin Pages @@ -72,14 +72,14 @@ Login as: `admin@orion.lu` or `samir.boulahtit@gmail.com` | Page | Dev URL | |------|---------| -| Programs Dashboard | `http://localhost:9999/platforms/loyalty/admin/loyalty/programs` | -| Analytics | `http://localhost:9999/platforms/loyalty/admin/loyalty/analytics` | -| WizaCorp Detail | `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/1` | -| WizaCorp Settings | `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/1/settings` | -| Fashion Group Detail | `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/2` | -| Fashion Group Settings | `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/2/settings` | -| BookWorld Detail | `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/3` | -| BookWorld Settings | `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/3/settings` | +| Programs Dashboard | `http://localhost:8000/platforms/loyalty/admin/loyalty/programs` | +| Analytics | `http://localhost:8000/platforms/loyalty/admin/loyalty/analytics` | +| WizaCorp Detail | `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/1` | +| WizaCorp Settings | `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/1/settings` | +| Fashion Group Detail | `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/2` | +| Fashion Group Settings | `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/2/settings` | +| BookWorld Detail | `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/3` | +| BookWorld Settings | `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/3/settings` | ### 2. Merchant Owner / Store Pages @@ -89,87 +89,87 @@ Login as the store owner, then navigate to any of their stores. | Page | Dev URL | |------|---------| -| Terminal | `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/terminal` | -| Cards | `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/cards` | -| Settings | `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/settings` | -| Stats | `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/stats` | -| Enroll Customer | `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/enroll` | +| Terminal | `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/terminal` | +| Cards | `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/cards` | +| Settings | `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/settings` | +| Stats | `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/stats` | +| Enroll Customer | `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/enroll` | **Fashion Group (jane.owner@fashiongroup.com):** | Page | Dev URL | |------|---------| -| Terminal | `http://localhost:9999/platforms/loyalty/store/FASHIONHUB/loyalty/terminal` | -| Cards | `http://localhost:9999/platforms/loyalty/store/FASHIONHUB/loyalty/cards` | -| Settings | `http://localhost:9999/platforms/loyalty/store/FASHIONHUB/loyalty/settings` | -| Stats | `http://localhost:9999/platforms/loyalty/store/FASHIONHUB/loyalty/stats` | -| Enroll Customer | `http://localhost:9999/platforms/loyalty/store/FASHIONHUB/loyalty/enroll` | +| Terminal | `http://localhost:8000/platforms/loyalty/store/FASHIONHUB/loyalty/terminal` | +| Cards | `http://localhost:8000/platforms/loyalty/store/FASHIONHUB/loyalty/cards` | +| Settings | `http://localhost:8000/platforms/loyalty/store/FASHIONHUB/loyalty/settings` | +| Stats | `http://localhost:8000/platforms/loyalty/store/FASHIONHUB/loyalty/stats` | +| Enroll Customer | `http://localhost:8000/platforms/loyalty/store/FASHIONHUB/loyalty/enroll` | **BookWorld (bob.owner@bookworld.com):** | Page | Dev URL | |------|---------| -| Terminal | `http://localhost:9999/platforms/loyalty/store/BOOKSTORE/loyalty/terminal` | -| Cards | `http://localhost:9999/platforms/loyalty/store/BOOKSTORE/loyalty/cards` | -| Settings | `http://localhost:9999/platforms/loyalty/store/BOOKSTORE/loyalty/settings` | -| Stats | `http://localhost:9999/platforms/loyalty/store/BOOKSTORE/loyalty/stats` | -| Enroll Customer | `http://localhost:9999/platforms/loyalty/store/BOOKSTORE/loyalty/enroll` | +| Terminal | `http://localhost:8000/platforms/loyalty/store/BOOKSTORE/loyalty/terminal` | +| Cards | `http://localhost:8000/platforms/loyalty/store/BOOKSTORE/loyalty/cards` | +| Settings | `http://localhost:8000/platforms/loyalty/store/BOOKSTORE/loyalty/settings` | +| Stats | `http://localhost:8000/platforms/loyalty/store/BOOKSTORE/loyalty/stats` | +| Enroll Customer | `http://localhost:8000/platforms/loyalty/store/BOOKSTORE/loyalty/enroll` | ### 3. Customer Storefront Pages Login as a customer (e.g., `customer1@orion.example.com`). -!!! note "Store domain required" - Storefront pages require a store domain context. Only ORION (`orion.shop`) - and FASHIONHUB (`fashionhub.store`) have domains configured. In dev, storefront - routes may need to be accessed through the store's domain or platform path. +!!! note "Store code required in dev" + Storefront pages in dev require the store code in the URL path: + `/platforms/loyalty/storefront/{STORE_CODE}/...`. In production, the store + is resolved from the domain (custom domain, merchant domain, or subdomain). -| Page | Dev URL | +| Page | Dev URL (FASHIONHUB example) | |------|---------| -| Loyalty Dashboard | `http://localhost:9999/platforms/loyalty/account/loyalty` | -| Transaction History | `http://localhost:9999/platforms/loyalty/account/loyalty/history` | +| Loyalty Dashboard | `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/account/loyalty` | +| Transaction History | `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/account/loyalty/history` | ### 4. Public Pages (No Auth) -| Page | Dev URL | +| Page | Dev URL (FASHIONHUB example) | |------|---------| -| Self-Enrollment | `http://localhost:9999/platforms/loyalty/loyalty/join` | -| Enrollment Success | `http://localhost:9999/platforms/loyalty/loyalty/join/success` | +| Self-Enrollment | `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/loyalty/join` | +| Enrollment Success | `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/loyalty/join/success` | ### 5. API Endpoints -**Admin API** (prefix: `/platforms/loyalty/api/admin/loyalty/`): +**Admin API** (prefix: `/platforms/loyalty/api/v1/admin/loyalty/`): | Method | Dev URL | |--------|---------| -| GET | `http://localhost:9999/platforms/loyalty/api/admin/loyalty/programs` | -| GET | `http://localhost:9999/platforms/loyalty/api/admin/loyalty/stats` | +| GET | `http://localhost:8000/platforms/loyalty/api/v1/admin/loyalty/programs` | +| GET | `http://localhost:8000/platforms/loyalty/api/v1/admin/loyalty/stats` | -**Store API** (prefix: `/platforms/loyalty/api/store/loyalty/`): +**Store API** (prefix: `/platforms/loyalty/api/v1/store/loyalty/`): | Method | Endpoint | Dev URL | |--------|----------|---------| -| GET | program | `http://localhost:9999/platforms/loyalty/api/store/loyalty/program` | -| POST | program | `http://localhost:9999/platforms/loyalty/api/store/loyalty/program` | -| POST | stamp | `http://localhost:9999/platforms/loyalty/api/store/loyalty/stamp` | -| POST | points | `http://localhost:9999/platforms/loyalty/api/store/loyalty/points` | -| POST | enroll | `http://localhost:9999/platforms/loyalty/api/store/loyalty/cards/enroll` | -| POST | lookup | `http://localhost:9999/platforms/loyalty/api/store/loyalty/cards/lookup` | +| GET | program | `http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/program` | +| POST | program | `http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/program` | +| POST | stamp | `http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/stamp` | +| POST | points | `http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/points` | +| POST | enroll | `http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/cards/enroll` | +| POST | lookup | `http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/cards/lookup` | -**Storefront API** (prefix: `/platforms/loyalty/api/storefront/`): +**Storefront API** (prefix: `/platforms/loyalty/api/v1/storefront/`): | Method | Endpoint | Dev URL | |--------|----------|---------| -| GET | program | `http://localhost:9999/platforms/loyalty/api/storefront/loyalty/program` | -| POST | enroll | `http://localhost:9999/platforms/loyalty/api/storefront/loyalty/enroll` | -| GET | card | `http://localhost:9999/platforms/loyalty/api/storefront/loyalty/card` | -| GET | transactions | `http://localhost:9999/platforms/loyalty/api/storefront/loyalty/transactions` | +| GET | program | `http://localhost:8000/platforms/loyalty/api/v1/storefront/loyalty/program` | +| POST | enroll | `http://localhost:8000/platforms/loyalty/api/v1/storefront/loyalty/enroll` | +| GET | card | `http://localhost:8000/platforms/loyalty/api/v1/storefront/loyalty/card` | +| GET | transactions | `http://localhost:8000/platforms/loyalty/api/v1/storefront/loyalty/transactions` | -**Public API** (prefix: `/platforms/loyalty/api/loyalty/`): +**Public API** (prefix: `/platforms/loyalty/api/v1/loyalty/`): | Method | Endpoint | Dev URL | |--------|----------|---------| -| GET | program | `http://localhost:9999/platforms/loyalty/api/loyalty/programs/ORION` | +| GET | program | `http://localhost:8000/platforms/loyalty/api/v1/loyalty/programs/ORION` | --- @@ -213,10 +213,10 @@ The store has a verified entry in the `store_domains` table. **All** store URLs | Method | Production URL | |--------|----------------| -| GET card | `https://orion.shop/api/storefront/loyalty/card` | -| GET transactions | `https://orion.shop/api/storefront/loyalty/transactions` | -| POST enroll | `https://orion.shop/api/storefront/loyalty/enroll` | -| GET program | `https://orion.shop/api/storefront/loyalty/program` | +| GET card | `https://orion.shop/api/v1/storefront/loyalty/card` | +| GET transactions | `https://orion.shop/api/v1/storefront/loyalty/transactions` | +| POST enroll | `https://orion.shop/api/v1/storefront/loyalty/enroll` | +| GET program | `https://orion.shop/api/v1/storefront/loyalty/program` | **Store backend (staff/owner):** @@ -234,12 +234,12 @@ The store has a verified entry in the `store_domains` table. **All** store URLs | Method | Production URL | |--------|----------------| -| GET program | `https://orion.shop/api/store/loyalty/program` | -| POST program | `https://orion.shop/api/store/loyalty/program` | -| POST stamp | `https://orion.shop/api/store/loyalty/stamp` | -| POST points | `https://orion.shop/api/store/loyalty/points` | -| POST enroll | `https://orion.shop/api/store/loyalty/cards/enroll` | -| POST lookup | `https://orion.shop/api/store/loyalty/cards/lookup` | +| GET program | `https://orion.shop/api/v1/store/loyalty/program` | +| POST program | `https://orion.shop/api/v1/store/loyalty/program` | +| POST stamp | `https://orion.shop/api/v1/store/loyalty/stamp` | +| POST points | `https://orion.shop/api/v1/store/loyalty/points` | +| POST enroll | `https://orion.shop/api/v1/store/loyalty/cards/enroll` | +| POST lookup | `https://orion.shop/api/v1/store/loyalty/cards/lookup` | ### Case 2: Store with merchant domain (e.g., `myloyaltyprogram.lu`) @@ -261,10 +261,10 @@ store when the URL includes `/store/{store_code}/...`. | Method | Production URL | |--------|----------------| -| GET card | `https://myloyaltyprogram.lu/api/storefront/loyalty/card` | -| GET transactions | `https://myloyaltyprogram.lu/api/storefront/loyalty/transactions` | -| POST enroll | `https://myloyaltyprogram.lu/api/storefront/loyalty/enroll` | -| GET program | `https://myloyaltyprogram.lu/api/storefront/loyalty/program` | +| GET card | `https://myloyaltyprogram.lu/api/v1/storefront/loyalty/card` | +| GET transactions | `https://myloyaltyprogram.lu/api/v1/storefront/loyalty/transactions` | +| POST enroll | `https://myloyaltyprogram.lu/api/v1/storefront/loyalty/enroll` | +| GET program | `https://myloyaltyprogram.lu/api/v1/storefront/loyalty/program` | **Store backend (staff/owner):** @@ -280,11 +280,11 @@ store when the URL includes `/store/{store_code}/...`. | Method | Production URL | |--------|----------------| -| GET program | `https://myloyaltyprogram.lu/api/store/loyalty/program` | -| POST stamp | `https://myloyaltyprogram.lu/api/store/loyalty/stamp` | -| POST points | `https://myloyaltyprogram.lu/api/store/loyalty/points` | -| POST enroll | `https://myloyaltyprogram.lu/api/store/loyalty/cards/enroll` | -| POST lookup | `https://myloyaltyprogram.lu/api/store/loyalty/cards/lookup` | +| GET program | `https://myloyaltyprogram.lu/api/v1/store/loyalty/program` | +| POST stamp | `https://myloyaltyprogram.lu/api/v1/store/loyalty/stamp` | +| POST points | `https://myloyaltyprogram.lu/api/v1/store/loyalty/points` | +| POST enroll | `https://myloyaltyprogram.lu/api/v1/store/loyalty/cards/enroll` | +| POST lookup | `https://myloyaltyprogram.lu/api/v1/store/loyalty/cards/lookup` | !!! note "Merchant domain resolves to first active store" When a customer visits `myloyaltyprogram.lu` without a `/store/{code}/...` path, @@ -310,10 +310,10 @@ The store has no entry in `store_domains` and the merchant has no registered dom | Method | Production URL | |--------|----------------| -| GET card | `https://bookstore.rewardflow.lu/api/storefront/loyalty/card` | -| GET transactions | `https://bookstore.rewardflow.lu/api/storefront/loyalty/transactions` | -| POST enroll | `https://bookstore.rewardflow.lu/api/storefront/loyalty/enroll` | -| GET program | `https://bookstore.rewardflow.lu/api/storefront/loyalty/program` | +| GET card | `https://bookstore.rewardflow.lu/api/v1/storefront/loyalty/card` | +| GET transactions | `https://bookstore.rewardflow.lu/api/v1/storefront/loyalty/transactions` | +| POST enroll | `https://bookstore.rewardflow.lu/api/v1/storefront/loyalty/enroll` | +| GET program | `https://bookstore.rewardflow.lu/api/v1/storefront/loyalty/program` | **Store backend (staff/owner):** @@ -329,11 +329,11 @@ The store has no entry in `store_domains` and the merchant has no registered dom | Method | Production URL | |--------|----------------| -| GET program | `https://bookstore.rewardflow.lu/api/store/loyalty/program` | -| POST stamp | `https://bookstore.rewardflow.lu/api/store/loyalty/stamp` | -| POST points | `https://bookstore.rewardflow.lu/api/store/loyalty/points` | -| POST enroll | `https://bookstore.rewardflow.lu/api/store/loyalty/cards/enroll` | -| POST lookup | `https://bookstore.rewardflow.lu/api/store/loyalty/cards/lookup` | +| GET program | `https://bookstore.rewardflow.lu/api/v1/store/loyalty/program` | +| POST stamp | `https://bookstore.rewardflow.lu/api/v1/store/loyalty/stamp` | +| POST points | `https://bookstore.rewardflow.lu/api/v1/store/loyalty/points` | +| POST enroll | `https://bookstore.rewardflow.lu/api/v1/store/loyalty/cards/enroll` | +| POST lookup | `https://bookstore.rewardflow.lu/api/v1/store/loyalty/cards/lookup` | ### Platform Admin & Public API (always on platform domain) @@ -343,10 +343,10 @@ The store has no entry in `store_domains` and the merchant has no registered dom | Admin Analytics | `https://rewardflow.lu/admin/loyalty/analytics` | | Admin Merchant Detail | `https://rewardflow.lu/admin/loyalty/merchants/{id}` | | Admin Merchant Settings | `https://rewardflow.lu/admin/loyalty/merchants/{id}/settings` | -| Admin API - Programs | `GET https://rewardflow.lu/api/admin/loyalty/programs` | -| Admin API - Stats | `GET https://rewardflow.lu/api/admin/loyalty/stats` | -| Public API - Program | `GET https://rewardflow.lu/api/loyalty/programs/ORION` | -| Apple Wallet Pass | `GET https://rewardflow.lu/api/loyalty/passes/apple/{serial}.pkpass` | +| Admin API - Programs | `GET https://rewardflow.lu/api/v1/admin/loyalty/programs` | +| Admin API - Stats | `GET https://rewardflow.lu/api/v1/admin/loyalty/stats` | +| Public API - Program | `GET https://rewardflow.lu/api/v1/loyalty/programs/ORION` | +| Apple Wallet Pass | `GET https://rewardflow.lu/api/v1/loyalty/passes/apple/{serial}.pkpass` | ### Domain configuration per store (current DB state) @@ -418,19 +418,19 @@ flowchart TD **Step 1: Subscribe to the platform** 1. Login as `john.owner@wizacorp.com` and navigate to billing: - - Dev: `http://localhost:9999/platforms/loyalty/store/ORION/billing` + - Dev: `http://localhost:8000/platforms/loyalty/store/ORION/billing` - Prod (custom domain): `https://orion.shop/store/ORION/billing` - Prod (subdomain): `https://orion.rewardflow.lu/store/ORION/billing` 2. View available subscription tiers: - - API Dev: `GET http://localhost:9999/platforms/loyalty/api/v1/store/billing/tiers` + - API Dev: `GET http://localhost:8000/platforms/loyalty/api/v1/store/billing/tiers` - API Prod: `GET https://{store_domain}/api/v1/store/billing/tiers` 3. Select a tier and initiate Stripe checkout: - - API Dev: `POST http://localhost:9999/platforms/loyalty/api/v1/store/billing/checkout` + - API Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/billing/checkout` - API Prod: `POST https://{store_domain}/api/v1/store/billing/checkout` 4. Complete payment on Stripe checkout page 5. Webhook `checkout.session.completed` activates the subscription 6. Verify subscription is active: - - API Dev: `GET http://localhost:9999/platforms/loyalty/api/v1/store/billing/subscription` + - API Dev: `GET http://localhost:8000/platforms/loyalty/api/v1/store/billing/subscription` - API Prod: `GET https://{store_domain}/api/v1/store/billing/subscription` **Step 2: Register merchant domain (admin action)** @@ -440,19 +440,19 @@ flowchart TD registers the domain on behalf of the merchant via the admin API. 1. Platform admin registers a merchant domain: - - API Dev: `POST http://localhost:9999/platforms/loyalty/api/v1/admin/merchants/{merchant_id}/domains` + - API Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/admin/merchants/{merchant_id}/domains` - API Prod: `POST https://rewardflow.lu/api/v1/admin/merchants/{merchant_id}/domains` - Body: `{"domain": "myloyaltyprogram.lu", "is_primary": true}` 2. The API returns a `verification_token` for DNS verification 3. Get DNS verification instructions: - - API Dev: `GET http://localhost:9999/platforms/loyalty/api/v1/admin/merchants/domains/merchant/{domain_id}/verification-instructions` + - API Dev: `GET http://localhost:8000/platforms/loyalty/api/v1/admin/merchants/domains/merchant/{domain_id}/verification-instructions` - API Prod: `GET https://rewardflow.lu/api/v1/admin/merchants/domains/merchant/{domain_id}/verification-instructions` 4. Merchant adds a DNS TXT record: `_orion-verify.myloyaltyprogram.lu TXT {verification_token}` 5. Verify the domain: - - API Dev: `POST http://localhost:9999/platforms/loyalty/api/v1/admin/merchants/domains/merchant/{domain_id}/verify` + - API Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/admin/merchants/domains/merchant/{domain_id}/verify` - API Prod: `POST https://rewardflow.lu/api/v1/admin/merchants/domains/merchant/{domain_id}/verify` 6. Activate the domain: - - API Dev: `PUT http://localhost:9999/platforms/loyalty/api/v1/admin/merchants/domains/merchant/{domain_id}` + - API Dev: `PUT http://localhost:8000/platforms/loyalty/api/v1/admin/merchants/domains/merchant/{domain_id}` - API Prod: `PUT https://rewardflow.lu/api/v1/admin/merchants/domains/merchant/{domain_id}` - Body: `{"is_active": true}` 7. All merchant stores now inherit `myloyaltyprogram.lu` as their effective domain @@ -462,7 +462,7 @@ flowchart TD If a store needs its own domain (e.g., ORION is a major brand and wants `mysuperloyaltyprogram.lu`): 1. Platform admin registers a store domain: - - API Dev: `POST http://localhost:9999/platforms/loyalty/api/v1/admin/stores/{store_id}/domains` + - API Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/admin/stores/{store_id}/domains` - API Prod: `POST https://rewardflow.lu/api/v1/admin/stores/{store_id}/domains` - Body: `{"domain": "mysuperloyaltyprogram.lu", "is_primary": true}` 2. Follow the same DNS verification and activation flow as merchant domains @@ -507,25 +507,25 @@ flowchart TD **Steps:** 1. Login as `john.owner@wizacorp.com` at: - - Dev: `http://localhost:9999/platforms/loyalty/store/ORION/login` + - Dev: `http://localhost:8000/platforms/loyalty/store/ORION/login` - Prod (custom domain): `https://orion.shop/store/ORION/login` - Prod (subdomain): `https://orion.rewardflow.lu/store/ORION/login` 2. Navigate to loyalty settings: - - Dev: `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/settings` + - Dev: `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/settings` - Prod (custom domain): `https://orion.shop/store/ORION/loyalty/settings` - Prod (subdomain): `https://orion.rewardflow.lu/store/ORION/loyalty/settings` 3. Create a new loyalty program: - - Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/program` - - Prod: `POST https://{store_domain}/api/store/loyalty/program` + - Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/program` + - Prod: `POST https://{store_domain}/api/v1/store/loyalty/program` 4. Choose loyalty type (stamps, points, or hybrid) 5. Configure program parameters (stamp target, points-per-euro, rewards) 6. Set branding (card color, logo, hero image) 7. Configure anti-fraud (cooldown, daily limits, PIN requirements) 8. Create staff PINs: - - Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/pins` - - Prod: `POST https://{store_domain}/api/store/loyalty/pins` + - Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/pins` + - Prod: `POST https://{store_domain}/api/v1/store/loyalty/pins` 9. Verify program is live - check from another store (same merchant): - - Dev: `http://localhost:9999/platforms/loyalty/store/WIZAGADGETS/loyalty/settings` + - Dev: `http://localhost:8000/platforms/loyalty/store/WIZAGADGETS/loyalty/settings` - Prod (subdomain): `https://wizagadgets.rewardflow.lu/store/WIZAGADGETS/loyalty/settings` **Expected blockers in current state:** @@ -563,23 +563,23 @@ flowchart TD **Steps:** 1. Login as `alice.manager@wizacorp.com` and open the terminal: - - Dev: `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/terminal` + - Dev: `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/terminal` - Prod: `https://{store_domain}/store/ORION/loyalty/terminal` 2. Scan customer QR code or enter card number: - - Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/cards/lookup` - - Prod: `POST https://{store_domain}/api/store/loyalty/cards/lookup` + - Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/cards/lookup` + - Prod: `POST https://{store_domain}/api/v1/store/loyalty/cards/lookup` 3. Enter staff PIN for verification 4. Add stamp: - - Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/stamp` - - Prod: `POST https://{store_domain}/api/store/loyalty/stamp` + - Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/stamp` + - Prod: `POST https://{store_domain}/api/v1/store/loyalty/stamp` 5. If target reached, redeem reward: - - Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/stamp/redeem` - - Prod: `POST https://{store_domain}/api/store/loyalty/stamp/redeem` + - Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/stamp/redeem` + - Prod: `POST https://{store_domain}/api/v1/store/loyalty/stamp/redeem` 6. View updated card: - - Dev: `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/cards/{card_id}` + - Dev: `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/cards/{card_id}` - Prod: `https://{store_domain}/store/ORION/loyalty/cards/{card_id}` 7. Browse all cards: - - Dev: `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/cards` + - Dev: `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/cards` - Prod: `https://{store_domain}/store/ORION/loyalty/cards` **Anti-fraud scenarios to test:** @@ -611,20 +611,20 @@ flowchart TD **Steps:** 1. Open the terminal: - - Dev: `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/terminal` + - Dev: `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/terminal` - Prod: `https://{store_domain}/store/ORION/loyalty/terminal` 2. Lookup card: - - Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/cards/lookup` - - Prod: `POST https://{store_domain}/api/store/loyalty/cards/lookup` + - Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/cards/lookup` + - Prod: `POST https://{store_domain}/api/v1/store/loyalty/cards/lookup` 3. Enter purchase amount (e.g., 25.00 EUR) 4. Earn points (auto-calculated at 10 pts/EUR = 250 points): - - Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/points` - - Prod: `POST https://{store_domain}/api/store/loyalty/points` + - Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/points` + - Prod: `POST https://{store_domain}/api/v1/store/loyalty/points` 5. If enough balance, redeem points for reward: - - Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/points/redeem` - - Prod: `POST https://{store_domain}/api/store/loyalty/points/redeem` + - Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/points/redeem` + - Prod: `POST https://{store_domain}/api/v1/store/loyalty/points/redeem` 6. Check store-level stats: - - Dev: `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/stats` + - Dev: `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/stats` - Prod: `https://{store_domain}/store/ORION/loyalty/stats` --- @@ -647,21 +647,21 @@ flowchart TD **Steps:** 1. Visit the public enrollment page: - - Dev: `http://localhost:9999/platforms/loyalty/loyalty/join` - - Prod (custom domain): `https://orion.shop/loyalty/join` + - Dev: `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/loyalty/join` + - Prod (custom domain): `https://fashionhub.store/loyalty/join` - Prod (subdomain): `https://bookstore.rewardflow.lu/loyalty/join` 2. Fill in enrollment form (email, name) 3. Submit enrollment: - - Dev: `POST http://localhost:9999/platforms/loyalty/api/storefront/loyalty/enroll` - - Prod (custom domain): `POST https://orion.shop/api/storefront/loyalty/enroll` - - Prod (subdomain): `POST https://bookstore.rewardflow.lu/api/storefront/loyalty/enroll` + - Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/storefront/loyalty/enroll` + - Prod (custom domain): `POST https://fashionhub.store/api/v1/storefront/loyalty/enroll` + - Prod (subdomain): `POST https://bookstore.rewardflow.lu/api/v1/storefront/loyalty/enroll` 4. Redirected to success page: - - Dev: `http://localhost:9999/platforms/loyalty/loyalty/join/success?card=XXXX-XXXX-XXXX` - - Prod (custom domain): `https://orion.shop/loyalty/join/success?card=XXXX-XXXX-XXXX` + - Dev: `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/loyalty/join/success?card=XXXX-XXXX-XXXX` + - Prod (custom domain): `https://fashionhub.store/loyalty/join/success?card=XXXX-XXXX-XXXX` - Prod (subdomain): `https://bookstore.rewardflow.lu/loyalty/join/success?card=XXXX-XXXX-XXXX` 5. Optionally download Apple Wallet pass: - - Dev: `GET http://localhost:9999/platforms/loyalty/api/loyalty/passes/apple/{serial_number}.pkpass` - - Prod: `GET https://rewardflow.lu/api/loyalty/passes/apple/{serial_number}.pkpass` + - Dev: `GET http://localhost:8000/platforms/loyalty/api/v1/loyalty/passes/apple/{serial_number}.pkpass` + - Prod: `GET https://rewardflow.lu/api/v1/loyalty/passes/apple/{serial_number}.pkpass` --- @@ -674,17 +674,17 @@ flowchart TD 1. Login as customer at the storefront 2. View loyalty dashboard (card balance, available rewards): - - Dev: `http://localhost:9999/platforms/loyalty/account/loyalty` - - Prod (custom domain): `https://orion.shop/account/loyalty` + - Dev: `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/account/loyalty` + - Prod (custom domain): `https://fashionhub.store/account/loyalty` - Prod (subdomain): `https://bookstore.rewardflow.lu/account/loyalty` - - API Dev: `GET http://localhost:9999/platforms/loyalty/api/storefront/loyalty/card` - - API Prod: `GET https://orion.shop/api/storefront/loyalty/card` + - API Dev: `GET http://localhost:8000/platforms/loyalty/api/v1/storefront/loyalty/card` + - API Prod: `GET https://fashionhub.store/api/v1/storefront/loyalty/card` 3. View full transaction history: - - Dev: `http://localhost:9999/platforms/loyalty/account/loyalty/history` - - Prod (custom domain): `https://orion.shop/account/loyalty/history` + - Dev: `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/account/loyalty/history` + - Prod (custom domain): `https://fashionhub.store/account/loyalty/history` - Prod (subdomain): `https://bookstore.rewardflow.lu/account/loyalty/history` - - API Dev: `GET http://localhost:9999/platforms/loyalty/api/storefront/loyalty/transactions` - - API Prod: `GET https://orion.shop/api/storefront/loyalty/transactions` + - API Dev: `GET http://localhost:8000/platforms/loyalty/api/v1/storefront/loyalty/transactions` + - API Prod: `GET https://fashionhub.store/api/v1/storefront/loyalty/transactions` --- @@ -697,22 +697,22 @@ flowchart TD 1. Login as admin 2. View all programs: - - Dev: `http://localhost:9999/platforms/loyalty/admin/loyalty/programs` + - Dev: `http://localhost:8000/platforms/loyalty/admin/loyalty/programs` - Prod: `https://rewardflow.lu/admin/loyalty/programs` 3. View platform-wide analytics: - - Dev: `http://localhost:9999/platforms/loyalty/admin/loyalty/analytics` + - Dev: `http://localhost:8000/platforms/loyalty/admin/loyalty/analytics` - Prod: `https://rewardflow.lu/admin/loyalty/analytics` 4. Drill into WizaCorp's program: - - Dev: `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/1` + - Dev: `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/1` - Prod: `https://rewardflow.lu/admin/loyalty/merchants/1` 5. Manage WizaCorp's merchant-level settings: - - Dev: `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/1/settings` + - Dev: `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/1/settings` - Prod: `https://rewardflow.lu/admin/loyalty/merchants/1/settings` - - API Dev: `PATCH http://localhost:9999/platforms/loyalty/api/admin/loyalty/merchants/1/settings` - - API Prod: `PATCH https://rewardflow.lu/api/admin/loyalty/merchants/1/settings` + - API Dev: `PATCH http://localhost:8000/platforms/loyalty/api/v1/admin/loyalty/merchants/1/settings` + - API Prod: `PATCH https://rewardflow.lu/api/v1/admin/loyalty/merchants/1/settings` 6. Adjust settings: PIN policy, self-enrollment toggle, void permissions 7. Check other merchants: - - Dev: `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/2` + - Dev: `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/2` - Prod: `https://rewardflow.lu/admin/loyalty/merchants/2` --- @@ -725,21 +725,21 @@ flowchart TD **Steps:** 1. Open terminal and lookup card: - - Dev: `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/terminal` + - Dev: `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/terminal` - Prod: `https://{store_domain}/store/ORION/loyalty/terminal` - - Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/cards/lookup` - - Prod: `POST https://{store_domain}/api/store/loyalty/cards/lookup` + - Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/cards/lookup` + - Prod: `POST https://{store_domain}/api/v1/store/loyalty/cards/lookup` 2. View the card's transaction history to find the transaction to void: - - Dev: `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/cards/{card_id}` + - Dev: `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/cards/{card_id}` - Prod: `https://{store_domain}/store/ORION/loyalty/cards/{card_id}` - - API Dev: `GET http://localhost:9999/platforms/loyalty/api/store/loyalty/cards/{card_id}/transactions` - - API Prod: `GET https://{store_domain}/api/store/loyalty/cards/{card_id}/transactions` + - API Dev: `GET http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/cards/{card_id}/transactions` + - API Prod: `GET https://{store_domain}/api/v1/store/loyalty/cards/{card_id}/transactions` 3. Void a stamp transaction: - - Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/stamp/void` - - Prod: `POST https://{store_domain}/api/store/loyalty/stamp/void` + - Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/stamp/void` + - Prod: `POST https://{store_domain}/api/v1/store/loyalty/stamp/void` 4. Or void a points transaction: - - Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/points/void` - - Prod: `POST https://{store_domain}/api/store/loyalty/points/void` + - Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/points/void` + - Prod: `POST https://{store_domain}/api/v1/store/loyalty/points/void` 5. Verify: original and void transactions are linked in the audit log --- @@ -751,28 +751,28 @@ flowchart TD **Precondition:** Cross-location redemption must be enabled in merchant settings: -- Dev: `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/1/settings` +- Dev: `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/1/settings` - Prod: `https://rewardflow.lu/admin/loyalty/merchants/1/settings` **Steps:** 1. Staff at ORION adds stamps to customer's card: - - Dev: `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/terminal` + - Dev: `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/terminal` - Prod: `https://{store_domain}/store/ORION/loyalty/terminal` - - Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/stamp` - - Prod: `POST https://{store_domain}/api/store/loyalty/stamp` + - Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/stamp` + - Prod: `POST https://{store_domain}/api/v1/store/loyalty/stamp` 2. Customer visits WIZAGADGETS 3. Staff at WIZAGADGETS looks up the same card: - - Dev: `http://localhost:9999/platforms/loyalty/store/WIZAGADGETS/loyalty/terminal` + - Dev: `http://localhost:8000/platforms/loyalty/store/WIZAGADGETS/loyalty/terminal` - Prod: `https://{store_domain}/store/WIZAGADGETS/loyalty/terminal` - - Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/cards/lookup` - - Prod: `POST https://{store_domain}/api/store/loyalty/cards/lookup` + - Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/cards/lookup` + - Prod: `POST https://{store_domain}/api/v1/store/loyalty/cards/lookup` 4. Card is found (same merchant) with accumulated stamps 5. Staff at WIZAGADGETS redeems the reward: - - Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/stamp/redeem` - - Prod: `POST https://{store_domain}/api/store/loyalty/stamp/redeem` + - Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/stamp/redeem` + - Prod: `POST https://{store_domain}/api/v1/store/loyalty/stamp/redeem` 6. Verify transaction history shows both stores: - - Dev: `http://localhost:9999/platforms/loyalty/store/WIZAGADGETS/loyalty/cards/{card_id}` + - Dev: `http://localhost:8000/platforms/loyalty/store/WIZAGADGETS/loyalty/cards/{card_id}` - Prod: `https://{store_domain}/store/WIZAGADGETS/loyalty/cards/{card_id}` --- diff --git a/app/modules/loyalty/routes/api/admin.py b/app/modules/loyalty/routes/api/admin.py index 19d07649..08f3de47 100644 --- a/app/modules/loyalty/routes/api/admin.py +++ b/app/modules/loyalty/routes/api/admin.py @@ -259,3 +259,17 @@ def get_platform_stats( ): """Get platform-wide loyalty statistics.""" return program_service.get_platform_stats(db) + + +# ============================================================================= +# Wallet Integration Status +# ============================================================================= + + +@router.get("/wallet-status") +def get_wallet_status( + current_user: User = Depends(get_current_admin_api), + db: Session = Depends(get_db), +): + """Get wallet integration status for the platform.""" + return program_service.get_wallet_integration_status(db) diff --git a/app/modules/loyalty/services/apple_wallet_service.py b/app/modules/loyalty/services/apple_wallet_service.py index 5071d533..7bc9d277 100644 --- a/app/modules/loyalty/services/apple_wallet_service.py +++ b/app/modules/loyalty/services/apple_wallet_service.py @@ -48,6 +48,34 @@ class AppleWalletService: and config.apple_signer_key_path ) + def validate_config(self) -> dict[str, Any]: + """Validate Apple Wallet configuration.""" + import os + + result: dict[str, Any] = { + "configured": self.is_configured, + "pass_type_id": config.apple_pass_type_id, + "team_id": config.apple_team_id, + "credentials_valid": False, + "errors": [], + } + + if not self.is_configured: + return result + + for label, path in [ + ("WWDR certificate", config.apple_wwdr_cert_path), + ("Signer certificate", config.apple_signer_cert_path), + ("Signer key", config.apple_signer_key_path), + ]: + if not os.path.isfile(path): + result["errors"].append(f"{label} not found: {path}") + + if not result["errors"]: + result["credentials_valid"] = True + + return result + # ========================================================================= # Auth Verification # ========================================================================= @@ -628,18 +656,71 @@ class AppleWalletService: """ Send an empty push notification to trigger pass update. - Apple Wallet will then call our web service to fetch the updated pass. + Apple Wallet will call our web service to fetch the updated pass. + Uses APNs HTTP/2 API with certificate-based authentication. """ - # This would use APNs to send the push notification - # For now, we'll log and skip the actual push - logger.debug(f"Would send push to token {push_token[:8]}...") + if not self.is_configured: + logger.debug("Apple Wallet not configured, skipping push") + return - # In production, you would use something like: - # from apns2.client import APNsClient - # from apns2.payload import Payload - # client = APNsClient(config.apple_signer_cert_path, use_sandbox=True) - # payload = Payload() - # client.send_notification(push_token, payload, "pass.com.example.loyalty") + import ssl + + import httpx + + # APNs endpoint (use sandbox for dev, production for prod) + from app.core.config import is_production + + if is_production(): + apns_host = "https://api.push.apple.com" + else: + apns_host = "https://api.sandbox.push.apple.com" + + url = f"{apns_host}/3/device/{push_token}" + + # Create SSL context with client certificate + ssl_context = ssl.create_default_context() + ssl_context.load_cert_chain( + certfile=config.apple_signer_cert_path, + keyfile=config.apple_signer_key_path, + ) + + # APNs requires empty payload for pass updates + headers = { + "apns-topic": config.apple_pass_type_id, + "apns-push-type": "background", + "apns-priority": "5", + } + + try: + with httpx.Client( + http2=True, + verify=ssl_context, + timeout=10.0, + ) as client: + response = client.post( + url, + headers=headers, + content=b"{}", + ) + + if response.status_code == 200: + logger.debug( + "APNs push sent to token %s...", push_token[:8] + ) + elif response.status_code == 410: + logger.info( + "APNs token %s... is no longer valid (device unregistered)", + push_token[:8], + ) + else: + logger.warning( + "APNs push failed for token %s...: %s %s", + push_token[:8], + response.status_code, + response.text, + ) + except Exception as exc: # noqa: BLE001 + logger.error("APNs push error for token %s...: %s", push_token[:8], exc) # Singleton instance diff --git a/app/modules/loyalty/services/google_wallet_service.py b/app/modules/loyalty/services/google_wallet_service.py index 67ea1d2c..437ff6a1 100644 --- a/app/modules/loyalty/services/google_wallet_service.py +++ b/app/modules/loyalty/services/google_wallet_service.py @@ -7,9 +7,14 @@ Handles Google Wallet integration including: - Creating LoyaltyObject for cards - Updating objects on balance changes - Generating "Add to Wallet" URLs +- Startup config validation +- Retry logic for transient API failures """ +import json import logging +import time +from datetime import UTC, datetime, timedelta from typing import Any from sqlalchemy.orm import Session @@ -23,6 +28,51 @@ from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram logger = logging.getLogger(__name__) +# Retry configuration +MAX_RETRIES = 3 +RETRY_BACKOFF_BASE = 1 # seconds + + +def _retry_on_failure(func): + """Decorator that retries Google Wallet API calls on transient failures. + + Only retries on 5xx/network errors. 4xx errors (bad request, not found) + are not retryable and fail immediately. + """ + + def wrapper(*args, **kwargs): + last_exception = None + for attempt in range(MAX_RETRIES): + try: + return func(*args, **kwargs) + except WalletIntegrationException as exc: + last_exception = exc + # Don't retry client errors (400, 401, 403, 404, 409) + exc_msg = str(exc) + if any(f":{code}" in exc_msg or f" {code} " in exc_msg + for code in ("400", "401", "403", "404")): + logger.error("Google Wallet API client error (not retryable): %s", exc) + break + if attempt < MAX_RETRIES - 1: + wait = RETRY_BACKOFF_BASE * (2**attempt) + logger.warning( + "Google Wallet API failed (attempt %d/%d), retrying in %ds: %s", + attempt + 1, + MAX_RETRIES, + wait, + exc, + ) + time.sleep(wait) + else: + logger.error( + "Google Wallet API failed after %d attempts: %s", + MAX_RETRIES, + exc, + ) + raise last_exception # type: ignore[misc] + + return wrapper + class GoogleWalletService: """Service for Google Wallet integration.""" @@ -31,11 +81,70 @@ class GoogleWalletService: """Initialize the Google Wallet service.""" self._credentials = None self._http_client = None + self._signer = None @property def is_configured(self) -> bool: """Check if Google Wallet is configured.""" - return bool(settings.loyalty_google_issuer_id and settings.loyalty_google_service_account_json) + return bool( + settings.loyalty_google_issuer_id + and settings.loyalty_google_service_account_json + ) + + def validate_config(self) -> dict[str, Any]: + """ + Validate Google Wallet configuration at startup. + + Returns: + Dict with validation results including any errors found. + """ + import os + + result: dict[str, Any] = { + "configured": self.is_configured, + "issuer_id": settings.loyalty_google_issuer_id, + "service_account_path": settings.loyalty_google_service_account_json, + "credentials_valid": False, + "errors": [], + } + + if not self.is_configured: + return result + + sa_path = settings.loyalty_google_service_account_json + if not os.path.isfile(sa_path): + result["errors"].append(f"Service account file not found: {sa_path}") + return result + + try: + with open(sa_path) as f: + sa_data = json.load(f) + + required_fields = ["type", "project_id", "private_key", "client_email"] + for field in required_fields: + if field not in sa_data: + result["errors"].append( + f"Missing field in service account JSON: {field}" + ) + + if sa_data.get("type") != "service_account": + result["errors"].append( + f"Invalid credential type: {sa_data.get('type')} " + f"(expected 'service_account')" + ) + + if not result["errors"]: + self._get_credentials() + result["credentials_valid"] = True + result["service_account_email"] = sa_data.get("client_email") + result["project_id"] = sa_data.get("project_id") + + except json.JSONDecodeError as exc: + result["errors"].append(f"Invalid JSON in service account file: {exc}") + except Exception as exc: # noqa: BLE001 + result["errors"].append(f"Failed to load credentials: {exc}") + + return result def _get_credentials(self): """Get Google service account credentials.""" @@ -50,14 +159,32 @@ class GoogleWalletService: scopes = ["https://www.googleapis.com/auth/wallet_object.issuer"] - self._credentials = service_account.Credentials.from_service_account_file( - settings.loyalty_google_service_account_json, - scopes=scopes, + self._credentials = ( + service_account.Credentials.from_service_account_file( + settings.loyalty_google_service_account_json, + scopes=scopes, + ) ) return self._credentials - except (ValueError, OSError) as e: - logger.error(f"Failed to load Google credentials: {e}") - raise WalletIntegrationException("google", str(e)) + except (ValueError, OSError) as exc: + logger.error("Failed to load Google credentials: %s", exc) + raise WalletIntegrationException("google", str(exc)) + + def _get_signer(self): + """Get RSA signer from service account for JWT signing.""" + if self._signer: + return self._signer + + try: + from google.auth.crypt import RSASigner + + self._signer = RSASigner.from_service_account_file( + settings.loyalty_google_service_account_json, + ) + return self._signer + except Exception as exc: # noqa: BLE001 + logger.error("Failed to create RSA signer: %s", exc) + raise WalletIntegrationException("google", str(exc)) def _get_http_client(self): """Get authenticated HTTP client.""" @@ -70,14 +197,15 @@ class GoogleWalletService: credentials = self._get_credentials() self._http_client = AuthorizedSession(credentials) return self._http_client - except Exception as e: # noqa: EXC003 - logger.error(f"Failed to create Google HTTP client: {e}") - raise WalletIntegrationException("google", str(e)) + except Exception as exc: # noqa: BLE001 + logger.error("Failed to create Google HTTP client: %s", exc) + raise WalletIntegrationException("google", str(exc)) # ========================================================================= # LoyaltyClass Operations (Program-level) # ========================================================================= + @_retry_on_failure def create_class(self, db: Session, program: LoyaltyProgram) -> str: """ Create a LoyaltyClass for a loyalty program. @@ -95,17 +223,16 @@ class GoogleWalletService: issuer_id = settings.loyalty_google_issuer_id class_id = f"{issuer_id}.loyalty_program_{program.id}" + # issuerName is required by Google Wallet API + issuer_name = program.merchant.name if program.merchant else program.display_name + class_data = { "id": class_id, "issuerId": issuer_id, - "reviewStatus": "UNDER_REVIEW", + "issuerName": issuer_name, + "reviewStatus": "DRAFT", "programName": program.display_name, - "programLogo": { - "sourceUri": { - "uri": program.logo_url or "https://via.placeholder.com/100", - }, - }, - "hexBackgroundColor": program.card_color, + "hexBackgroundColor": program.card_color or "#4285F4", "localizedProgramName": { "defaultValue": { "language": "en", @@ -114,6 +241,15 @@ class GoogleWalletService: }, } + # programLogo is required by Google Wallet API + # Google must be able to fetch the image, so it needs a public URL + logo_url = program.logo_url + if not logo_url: + logo_url = settings.loyalty_default_logo_url + class_data["programLogo"] = { + "sourceUri": {"uri": logo_url}, + } + # Add hero image if configured if program.hero_image_url: class_data["heroImage"] = { @@ -128,14 +264,15 @@ class GoogleWalletService: ) 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}") + logger.info( + "Created Google Wallet class %s for program %s", + class_id, + program.id, + ) return class_id if response.status_code == 409: - # Class already exists program.google_class_id = class_id db.commit() return class_id @@ -146,10 +283,11 @@ class GoogleWalletService: ) except WalletIntegrationException: raise - except Exception as e: # noqa: EXC003 - logger.error(f"Failed to create Google Wallet class: {e}") - raise WalletIntegrationException("google", str(e)) + except Exception as exc: # noqa: BLE001 + logger.error("Failed to create Google Wallet class: %s", exc) + raise WalletIntegrationException("google", str(exc)) + @_retry_on_failure def update_class(self, db: Session, program: LoyaltyProgram) -> None: """Update a LoyaltyClass when program settings change.""" if not program.google_class_id: @@ -168,22 +306,25 @@ class GoogleWalletService: try: http = self._get_http_client() response = http.patch( - f"https://walletobjects.googleapis.com/walletobjects/v1/loyaltyClass/{program.google_class_id}", + "https://walletobjects.googleapis.com/walletobjects/v1/loyaltyClass/" + f"{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}" + "Failed to update Google Wallet class %s: %s", + program.google_class_id, + response.status_code, ) - except Exception as e: # noqa: EXC003 - logger.error(f"Failed to update Google Wallet class: {e}") + except Exception as exc: # noqa: BLE001 + logger.error("Failed to update Google Wallet class: %s", exc) # ========================================================================= # LoyaltyObject Operations (Card-level) # ========================================================================= + @_retry_on_failure def create_object(self, db: Session, card: LoyaltyCard) -> str: """ Create a LoyaltyObject for a loyalty card. @@ -200,7 +341,6 @@ class GoogleWalletService: program = card.program if not program.google_class_id: - # Create class first self.create_class(db, program) issuer_id = settings.loyalty_google_issuer_id @@ -218,11 +358,13 @@ class GoogleWalletService: 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}") + logger.info( + "Created Google Wallet object %s for card %s", + object_id, + card.id, + ) return object_id if response.status_code == 409: - # Object already exists card.google_object_id = object_id db.commit() return object_id @@ -233,10 +375,11 @@ class GoogleWalletService: ) except WalletIntegrationException: raise - except Exception as e: # noqa: EXC003 - logger.error(f"Failed to create Google Wallet object: {e}") - raise WalletIntegrationException("google", str(e)) + except Exception as exc: # noqa: BLE001 + logger.error("Failed to create Google Wallet object: %s", exc) + raise WalletIntegrationException("google", str(exc)) + @_retry_on_failure def update_object(self, db: Session, card: LoyaltyCard) -> None: """Update a LoyaltyObject when card balance changes.""" if not card.google_object_id: @@ -247,25 +390,31 @@ class GoogleWalletService: try: http = self._get_http_client() response = http.patch( - f"https://walletobjects.googleapis.com/walletobjects/v1/loyaltyObject/{card.google_object_id}", + "https://walletobjects.googleapis.com/walletobjects/v1/loyaltyObject/" + f"{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}") + logger.debug( + "Updated Google Wallet object for card %s", card.id + ) else: logger.warning( - f"Failed to update Google Wallet object {card.google_object_id}: " - f"{response.status_code}" + "Failed to update Google Wallet object %s: %s", + card.google_object_id, + response.status_code, ) - except Exception as e: # noqa: EXC003 - logger.error(f"Failed to update Google Wallet object: {e}") + except Exception as exc: # noqa: BLE001 + logger.error("Failed to update Google Wallet object: %s", exc) - def _build_object_data(self, card: LoyaltyCard, object_id: str) -> dict[str, Any]: + def _build_object_data( + self, card: LoyaltyCard, object_id: str + ) -> dict[str, Any]: """Build the LoyaltyObject data structure.""" program = card.program - object_data = { + object_data: dict[str, Any] = { "id": object_id, "classId": program.google_class_id, "state": "ACTIVE" if card.is_active else "INACTIVE", @@ -278,7 +427,6 @@ class GoogleWalletService: }, } - # Add loyalty points (stamps as points for display) if program.is_stamps_enabled: object_data["loyaltyPoints"] = { "label": "Stamps", @@ -286,7 +434,6 @@ class GoogleWalletService: "int": card.stamp_count, }, } - # Add secondary points showing target object_data["secondaryLoyaltyPoints"] = { "label": f"of {program.stamps_target}", "balance": { @@ -311,6 +458,9 @@ class GoogleWalletService: """ Get the "Add to Google Wallet" URL for a card. + Uses google.auth.crypt.RSASigner (public API) for JWT signing + instead of accessing private signer internals. + Args: db: Database session card: Loyalty card @@ -321,34 +471,34 @@ class GoogleWalletService: 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: - from datetime import datetime, timedelta - import jwt credentials = self._get_credentials() + signer = self._get_signer() + + now = datetime.now(tz=UTC) + origins = settings.loyalty_google_wallet_origins or [] claims = { "iss": credentials.service_account_email, "aud": "google", - "origins": [], + "origins": origins, "typ": "savetowallet", "payload": { "loyaltyObjects": [{"id": card.google_object_id}], }, - "iat": datetime.utcnow(), - "exp": datetime.utcnow() + timedelta(hours=1), + "iat": now, + "exp": now + timedelta(hours=1), } - # Sign with service account private key + # Sign using the RSASigner's key_id and key bytes (public API) token = jwt.encode( claims, - credentials._signer._key, + signer.key, algorithm="RS256", ) @@ -356,9 +506,49 @@ class GoogleWalletService: db.commit() return f"https://pay.google.com/gp/v/save/{token}" - except Exception as e: # noqa: EXC003 - logger.error(f"Failed to generate Google Wallet save URL: {e}") - raise WalletIntegrationException("google", str(e)) + except Exception as exc: # noqa: BLE001 + logger.error( + "Failed to generate Google Wallet save URL: %s", exc + ) + raise WalletIntegrationException("google", str(exc)) + + # ========================================================================= + # Class Approval + # ========================================================================= + + def get_class_status(self, class_id: str) -> dict[str, Any] | None: + """ + Check the review status of a LoyaltyClass. + + Args: + class_id: Google Wallet class ID + + Returns: + Dict with class status info or None if not found. + """ + if not self.is_configured: + return None + + try: + http = self._get_http_client() + response = http.get( + "https://walletobjects.googleapis.com/walletobjects/v1/loyaltyClass/" + f"{class_id}", + ) + + if response.status_code == 200: + data = response.json() + return { + "class_id": class_id, + "review_status": data.get("reviewStatus"), + "program_name": data.get("programName"), + } + return None + except Exception as exc: # noqa: BLE001 + logger.error( + "Failed to get Google Wallet class status: %s", exc + ) + return None # Singleton instance diff --git a/app/modules/loyalty/services/program_service.py b/app/modules/loyalty/services/program_service.py index b08a48be..fc9734ba 100644 --- a/app/modules/loyalty/services/program_service.py +++ b/app/modules/loyalty/services/program_service.py @@ -833,6 +833,62 @@ class ProgramService: "estimated_liability_cents": estimated_liability, } + def get_wallet_integration_status(self, db: Session) -> dict: + """Get wallet integration status for admin dashboard.""" + from app.modules.loyalty.models import LoyaltyCard + from app.modules.loyalty.services.apple_wallet_service import ( + apple_wallet_service, + ) + from app.modules.loyalty.services.google_wallet_service import ( + google_wallet_service, + ) + + # Google Wallet + google_config = google_wallet_service.validate_config() + google_classes = [] + if google_config["credentials_valid"]: + programs_with_class = ( + db.query(LoyaltyProgram) + .filter(LoyaltyProgram.google_class_id.isnot(None)) + .all() + ) + for prog in programs_with_class: + status = google_wallet_service.get_class_status( + prog.google_class_id, + ) + google_classes.append({ + "program_id": prog.id, + "program_name": prog.display_name, + "class_id": prog.google_class_id, + "review_status": status["review_status"] if status else "UNKNOWN", + }) + + google_objects = ( + db.query(LoyaltyCard) + .filter(LoyaltyCard.google_object_id.isnot(None)) + .count() + ) + + # Apple Wallet + apple_config = apple_wallet_service.validate_config() + apple_passes = ( + db.query(LoyaltyCard) + .filter(LoyaltyCard.apple_serial_number.isnot(None)) + .count() + ) + + return { + "google_wallet": { + **google_config, + "classes": google_classes, + "total_objects": google_objects, + }, + "apple_wallet": { + **apple_config, + "total_passes": apple_passes, + }, + } + def get_merchant_stats(self, db: Session, merchant_id: int) -> dict: """ Get statistics for a merchant's loyalty program across all locations. diff --git a/app/modules/loyalty/static/admin/js/loyalty-analytics.js b/app/modules/loyalty/static/admin/js/loyalty-analytics.js index 902e49d3..91d6f9b9 100644 --- a/app/modules/loyalty/static/admin/js/loyalty-analytics.js +++ b/app/modules/loyalty/static/admin/js/loyalty-analytics.js @@ -33,6 +33,10 @@ function adminLoyaltyAnalytics() { showMerchantDropdown: false, searchingMerchants: false, + // Wallet integration status + walletStatus: null, + walletStatusLoading: false, + loading: false, error: null, @@ -59,6 +63,7 @@ function adminLoyaltyAnalytics() { window._loyaltyAnalyticsInitialized = true; await this.loadStats(); + await this.loadWalletStatus(); loyaltyAnalyticsLog.info('=== LOYALTY ANALYTICS PAGE INITIALIZATION COMPLETE ==='); }, @@ -166,6 +171,21 @@ function adminLoyaltyAnalytics() { await this.loadStats(); }, + async loadWalletStatus() { + this.walletStatusLoading = true; + try { + const response = await apiClient.get('/admin/loyalty/wallet-status'); + if (response) { + this.walletStatus = response; + loyaltyAnalyticsLog.info('Wallet status loaded'); + } + } catch (error) { + loyaltyAnalyticsLog.error('Failed to load wallet status:', error); + } finally { + this.walletStatusLoading = false; + } + }, + formatNumber(num) { if (num === null || num === undefined) return '0'; return new Intl.NumberFormat('en-US').format(num); diff --git a/app/modules/loyalty/static/shared/img/default-logo-200.png b/app/modules/loyalty/static/shared/img/default-logo-200.png new file mode 100644 index 0000000000000000000000000000000000000000..38403dad8e5b01279c5fa9d2e73bdb0cdbb6461a GIT binary patch literal 11670 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≶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 + Wallet Integration Status + + +
+ +
+
+

Google Wallet

+ + + +
+ +
+ + +
+
+

Apple Wallet

+ + + +
+ +
+
+ +

Quick Actions

diff --git a/app/modules/loyalty/templates/loyalty/storefront/enroll-success.html b/app/modules/loyalty/templates/loyalty/storefront/enroll-success.html index e2c5fcf4..fd3aa3c5 100644 --- a/app/modules/loyalty/templates/loyalty/storefront/enroll-success.html +++ b/app/modules/loyalty/templates/loyalty/storefront/enroll-success.html @@ -24,7 +24,8 @@

Your Card Number

{{ enrolled_card_number or 'Loading...' }}

-
+

Save your card to your phone for easy access:

@@ -88,14 +89,15 @@ function customerLoyaltyEnrollSuccess() { return { ...storefrontLayoutData(), walletUrls: { google_wallet_url: null, apple_wallet_url: null }, - async init() { + init() { + // Read wallet URLs saved during enrollment (no auth needed) try { - const response = await apiClient.get('/storefront/loyalty/card'); - if (response && response.wallet_urls) { - this.walletUrls = response.wallet_urls; + const stored = sessionStorage.getItem('loyalty_wallet_urls'); + if (stored) { + this.walletUrls = JSON.parse(stored); + sessionStorage.removeItem('loyalty_wallet_urls'); } } catch (e) { - // Customer may not be authenticated (public enrollment) console.log('Could not load wallet URLs:', e.message); } } diff --git a/app/modules/loyalty/templates/loyalty/storefront/enroll.html b/app/modules/loyalty/templates/loyalty/storefront/enroll.html index a9d86699..fe458ee9 100644 --- a/app/modules/loyalty/templates/loyalty/storefront/enroll.html +++ b/app/modules/loyalty/templates/loyalty/storefront/enroll.html @@ -93,7 +93,13 @@ class="mt-1 w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary" style="color: var(--color-primary)"> - I agree to the Terms & Conditions + I agree to the + +
+ +{# TODO: Rework T&C strategy - current approach (small text field on program model) won't scale + for full legal T&C. Options: (1) leverage the CMS module to host T&C pages, or + (2) create a dedicated T&C page within the loyalty module. Decision pending. #} + +
+
+
+

Terms & Conditions

+ +
+
+ +
+ +
+
+
{% endblock %} {% block extra_scripts %} diff --git a/app/modules/loyalty/tests/unit/test_card_service.py b/app/modules/loyalty/tests/unit/test_card_service.py index aa02eefe..05d38299 100644 --- a/app/modules/loyalty/tests/unit/test_card_service.py +++ b/app/modules/loyalty/tests/unit/test_card_service.py @@ -196,7 +196,7 @@ class TestResolveCustomerId: assert customer.first_name == "Jane" assert customer.last_name == "Doe" assert customer.phone == "+352123456" - assert customer.hashed_password.startswith("!loyalty-enroll!") + assert customer.hashed_password.startswith("!enrollment!") assert customer.customer_number is not None assert customer.is_active is True