feat(loyalty): fix Google Wallet integration and improve enrollment flow
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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"}
|
||||
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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}`
|
||||
|
||||
---
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
BIN
app/modules/loyalty/static/shared/img/default-logo-200.png
Normal file
BIN
app/modules/loyalty/static/shared/img/default-logo-200.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
app/modules/loyalty/static/shared/img/default-logo-512.png
Normal file
BIN
app/modules/loyalty/static/shared/img/default-logo-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -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);
|
||||
|
||||
@@ -50,6 +50,109 @@
|
||||
{% set show_merchants_metric = true %}
|
||||
{% include "loyalty/shared/analytics-stats.html" %}
|
||||
|
||||
<!-- Wallet Integration Status -->
|
||||
<div class="mb-6 px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800" x-show="walletStatus">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
<span x-html="$icon('device-phone-mobile', 'w-5 h-5 inline mr-1')"></span>
|
||||
Wallet Integration Status
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Google Wallet -->
|
||||
<div class="p-4 border rounded-lg dark:border-gray-700" x-show="walletStatus?.google_wallet">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h4 class="font-medium text-gray-700 dark:text-gray-300">Google Wallet</h4>
|
||||
<template x-if="walletStatus?.google_wallet?.credentials_valid">
|
||||
<span class="px-2 py-1 text-xs font-medium text-green-700 bg-green-100 rounded-full dark:text-green-300 dark:bg-green-900/30">Connected</span>
|
||||
</template>
|
||||
<template x-if="walletStatus?.google_wallet?.configured && !walletStatus?.google_wallet?.credentials_valid">
|
||||
<span class="px-2 py-1 text-xs font-medium text-red-700 bg-red-100 rounded-full dark:text-red-300 dark:bg-red-900/30">Error</span>
|
||||
</template>
|
||||
<template x-if="!walletStatus?.google_wallet?.configured">
|
||||
<span class="px-2 py-1 text-xs font-medium text-gray-500 bg-gray-100 rounded-full dark:text-gray-400 dark:bg-gray-700">Not Configured</span>
|
||||
</template>
|
||||
</div>
|
||||
<template x-if="walletStatus?.google_wallet?.configured">
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">Issuer ID</span>
|
||||
<span class="text-gray-700 dark:text-gray-300" x-text="walletStatus.google_wallet.issuer_id"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">Project</span>
|
||||
<span class="text-gray-700 dark:text-gray-300" x-text="walletStatus.google_wallet.project_id || '-'"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">Wallet Objects</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300" x-text="walletStatus.google_wallet.total_objects || 0"></span>
|
||||
</div>
|
||||
<!-- Class statuses -->
|
||||
<template x-if="walletStatus.google_wallet.classes?.length > 0">
|
||||
<div class="mt-2 pt-2 border-t dark:border-gray-700">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Loyalty Classes</p>
|
||||
<template x-for="cls in walletStatus.google_wallet.classes" :key="cls.class_id">
|
||||
<div class="flex justify-between text-xs py-1">
|
||||
<span class="text-gray-600 dark:text-gray-400" x-text="cls.program_name"></span>
|
||||
<span class="px-1.5 py-0.5 rounded"
|
||||
:class="cls.review_status === 'APPROVED' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300' : cls.review_status === 'DRAFT' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'"
|
||||
x-text="cls.review_status"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Errors -->
|
||||
<template x-if="walletStatus.google_wallet.errors?.length > 0">
|
||||
<div class="mt-2 pt-2 border-t dark:border-gray-700">
|
||||
<template x-for="err in walletStatus.google_wallet.errors" :key="err">
|
||||
<p class="text-xs text-red-600 dark:text-red-400" x-text="err"></p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Apple Wallet -->
|
||||
<div class="p-4 border rounded-lg dark:border-gray-700" x-show="walletStatus?.apple_wallet">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h4 class="font-medium text-gray-700 dark:text-gray-300">Apple Wallet</h4>
|
||||
<template x-if="walletStatus?.apple_wallet?.credentials_valid">
|
||||
<span class="px-2 py-1 text-xs font-medium text-green-700 bg-green-100 rounded-full dark:text-green-300 dark:bg-green-900/30">Connected</span>
|
||||
</template>
|
||||
<template x-if="walletStatus?.apple_wallet?.configured && !walletStatus?.apple_wallet?.credentials_valid">
|
||||
<span class="px-2 py-1 text-xs font-medium text-red-700 bg-red-100 rounded-full dark:text-red-300 dark:bg-red-900/30">Error</span>
|
||||
</template>
|
||||
<template x-if="!walletStatus?.apple_wallet?.configured">
|
||||
<span class="px-2 py-1 text-xs font-medium text-gray-500 bg-gray-100 rounded-full dark:text-gray-400 dark:bg-gray-700">Not Configured</span>
|
||||
</template>
|
||||
</div>
|
||||
<template x-if="walletStatus?.apple_wallet?.configured">
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">Pass Type ID</span>
|
||||
<span class="text-gray-700 dark:text-gray-300" x-text="walletStatus.apple_wallet.pass_type_id"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">Team ID</span>
|
||||
<span class="text-gray-700 dark:text-gray-300" x-text="walletStatus.apple_wallet.team_id"></span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">Active Passes</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300" x-text="walletStatus.apple_wallet.total_passes || 0"></span>
|
||||
</div>
|
||||
<template x-if="walletStatus.apple_wallet.errors?.length > 0">
|
||||
<div class="mt-2 pt-2 border-t dark:border-gray-700">
|
||||
<template x-for="err in walletStatus.apple_wallet.errors" :key="err">
|
||||
<p class="text-xs text-red-600 dark:text-red-400" x-text="err"></p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">Quick Actions</h3>
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2">Your Card Number</p>
|
||||
<p class="text-2xl font-mono font-bold text-gray-900 dark:text-white">{{ enrolled_card_number or 'Loading...' }}</p>
|
||||
|
||||
<div class="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<div x-show="walletUrls.apple_wallet_url || walletUrls.google_wallet_url"
|
||||
class="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Save your card to your phone for easy access:
|
||||
</p>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)">
|
||||
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
I agree to the <a href="#" class="underline" style="color: var(--color-primary)">Terms & Conditions</a>
|
||||
I agree to the
|
||||
<template x-if="program?.terms_text">
|
||||
<a href="#" @click.prevent="showTerms = true" class="underline" style="color: var(--color-primary)">Terms & Conditions</a>
|
||||
</template>
|
||||
<template x-if="!program?.terms_text">
|
||||
<span class="underline" style="color: var(--color-primary)">Terms & Conditions</span>
|
||||
</template>
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex items-start">
|
||||
@@ -128,6 +134,37 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# 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 Modal -->
|
||||
<div x-show="showTerms" x-cloak
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50"
|
||||
@click.self="showTerms = false"
|
||||
@keydown.escape.window="showTerms = false">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-lg w-full max-h-[80vh] flex flex-col">
|
||||
<div class="flex items-center justify-between p-4 border-b dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Terms & Conditions</h3>
|
||||
<button @click="showTerms = false" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<span x-html="$icon('x-mark', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-4 overflow-y-auto text-sm text-gray-700 dark:text-gray-300 whitespace-pre-line" x-text="program?.terms_text"></div>
|
||||
<template x-if="program?.privacy_url">
|
||||
<div class="px-4 pb-2">
|
||||
<a :href="program.privacy_url" target="_blank" class="text-sm underline" style="color: var(--color-primary)">Privacy Policy</a>
|
||||
</div>
|
||||
</template>
|
||||
<div class="p-4 border-t dark:border-gray-700">
|
||||
<button @click="showTerms = false"
|
||||
class="w-full py-2 px-4 text-white font-medium rounded-lg"
|
||||
:style="'background-color: ' + (program?.card_color || 'var(--color-primary)')">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user