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 00000000..38403dad Binary files /dev/null and b/app/modules/loyalty/static/shared/img/default-logo-200.png differ diff --git a/app/modules/loyalty/static/shared/img/default-logo-512.png b/app/modules/loyalty/static/shared/img/default-logo-512.png new file mode 100644 index 00000000..bc918f18 Binary files /dev/null and b/app/modules/loyalty/static/shared/img/default-logo-512.png differ diff --git a/app/modules/loyalty/static/storefront/js/loyalty-enroll.js b/app/modules/loyalty/static/storefront/js/loyalty-enroll.js index 658e3ba2..173bbe83 100644 --- a/app/modules/loyalty/static/storefront/js/loyalty-enroll.js +++ b/app/modules/loyalty/static/storefront/js/loyalty-enroll.js @@ -25,6 +25,7 @@ function customerLoyaltyEnroll() { enrolled: false, enrolledCard: null, error: null, + showTerms: false, async init() { console.log('Customer loyalty enroll initializing...'); @@ -73,8 +74,13 @@ function customerLoyaltyEnroll() { if (response) { const cardNumber = response.card?.card_number || response.card_number; console.log('Enrollment successful:', cardNumber); - // Redirect to success page - extract base path from current URL - // Current page is at /storefront/loyalty/join, redirect to /storefront/loyalty/join/success + + // Store wallet URLs for the success page (no auth needed) + if (response.wallet_urls) { + sessionStorage.setItem('loyalty_wallet_urls', JSON.stringify(response.wallet_urls)); + } + + // Redirect to success page const currentPath = window.location.pathname; const successUrl = currentPath.replace(/\/join\/?$/, '/join/success') + '?card=' + encodeURIComponent(cardNumber); diff --git a/app/modules/loyalty/templates/loyalty/admin/analytics.html b/app/modules/loyalty/templates/loyalty/admin/analytics.html index bdf36270..adac780b 100644 --- a/app/modules/loyalty/templates/loyalty/admin/analytics.html +++ b/app/modules/loyalty/templates/loyalty/admin/analytics.html @@ -50,6 +50,109 @@ {% set show_merchants_metric = true %} {% include "loyalty/shared/analytics-stats.html" %} + +
Loyalty Classes
+ +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 + + Terms & Conditions + + + Terms & Conditions +