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_issuer_id: str | None = None
|
||||||
loyalty_google_service_account_json: str | None = None # Path to service account JSON
|
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"}
|
model_config = {"env_file": ".env"}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ async def lifespan(app: FastAPI):
|
|||||||
grafana_url=settings.grafana_url,
|
grafana_url=settings.grafana_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Validate wallet configurations
|
||||||
|
_validate_wallet_config()
|
||||||
|
|
||||||
logger.info("[OK] Application startup completed")
|
logger.info("[OK] Application startup completed")
|
||||||
|
|
||||||
yield
|
yield
|
||||||
@@ -53,6 +56,72 @@ async def lifespan(app: FastAPI):
|
|||||||
shutdown_observability()
|
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 ===
|
# === NEW HELPER FUNCTION ===
|
||||||
def check_database_ready():
|
def check_database_ready():
|
||||||
"""Check if database is ready (migrations have been run)."""
|
"""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
|
### 1. Platform Admin Pages
|
||||||
|
|
||||||
@@ -72,14 +72,14 @@ Login as: `admin@orion.lu` or `samir.boulahtit@gmail.com`
|
|||||||
|
|
||||||
| Page | Dev URL |
|
| Page | Dev URL |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| Programs Dashboard | `http://localhost:9999/platforms/loyalty/admin/loyalty/programs` |
|
| Programs Dashboard | `http://localhost:8000/platforms/loyalty/admin/loyalty/programs` |
|
||||||
| Analytics | `http://localhost:9999/platforms/loyalty/admin/loyalty/analytics` |
|
| Analytics | `http://localhost:8000/platforms/loyalty/admin/loyalty/analytics` |
|
||||||
| WizaCorp Detail | `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/1` |
|
| WizaCorp Detail | `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/1` |
|
||||||
| WizaCorp Settings | `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/1/settings` |
|
| WizaCorp Settings | `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/1/settings` |
|
||||||
| Fashion Group Detail | `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/2` |
|
| Fashion Group Detail | `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/2` |
|
||||||
| Fashion Group Settings | `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/2/settings` |
|
| Fashion Group Settings | `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/2/settings` |
|
||||||
| BookWorld Detail | `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/3` |
|
| BookWorld Detail | `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/3` |
|
||||||
| BookWorld Settings | `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/3/settings` |
|
| BookWorld Settings | `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/3/settings` |
|
||||||
|
|
||||||
### 2. Merchant Owner / Store Pages
|
### 2. Merchant Owner / Store Pages
|
||||||
|
|
||||||
@@ -89,87 +89,87 @@ Login as the store owner, then navigate to any of their stores.
|
|||||||
|
|
||||||
| Page | Dev URL |
|
| Page | Dev URL |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| Terminal | `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/terminal` |
|
| Terminal | `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/terminal` |
|
||||||
| Cards | `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/cards` |
|
| Cards | `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/cards` |
|
||||||
| Settings | `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/settings` |
|
| Settings | `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/settings` |
|
||||||
| Stats | `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/stats` |
|
| Stats | `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/stats` |
|
||||||
| Enroll Customer | `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/enroll` |
|
| Enroll Customer | `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/enroll` |
|
||||||
|
|
||||||
**Fashion Group (jane.owner@fashiongroup.com):**
|
**Fashion Group (jane.owner@fashiongroup.com):**
|
||||||
|
|
||||||
| Page | Dev URL |
|
| Page | Dev URL |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| Terminal | `http://localhost:9999/platforms/loyalty/store/FASHIONHUB/loyalty/terminal` |
|
| Terminal | `http://localhost:8000/platforms/loyalty/store/FASHIONHUB/loyalty/terminal` |
|
||||||
| Cards | `http://localhost:9999/platforms/loyalty/store/FASHIONHUB/loyalty/cards` |
|
| Cards | `http://localhost:8000/platforms/loyalty/store/FASHIONHUB/loyalty/cards` |
|
||||||
| Settings | `http://localhost:9999/platforms/loyalty/store/FASHIONHUB/loyalty/settings` |
|
| Settings | `http://localhost:8000/platforms/loyalty/store/FASHIONHUB/loyalty/settings` |
|
||||||
| Stats | `http://localhost:9999/platforms/loyalty/store/FASHIONHUB/loyalty/stats` |
|
| Stats | `http://localhost:8000/platforms/loyalty/store/FASHIONHUB/loyalty/stats` |
|
||||||
| Enroll Customer | `http://localhost:9999/platforms/loyalty/store/FASHIONHUB/loyalty/enroll` |
|
| Enroll Customer | `http://localhost:8000/platforms/loyalty/store/FASHIONHUB/loyalty/enroll` |
|
||||||
|
|
||||||
**BookWorld (bob.owner@bookworld.com):**
|
**BookWorld (bob.owner@bookworld.com):**
|
||||||
|
|
||||||
| Page | Dev URL |
|
| Page | Dev URL |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| Terminal | `http://localhost:9999/platforms/loyalty/store/BOOKSTORE/loyalty/terminal` |
|
| Terminal | `http://localhost:8000/platforms/loyalty/store/BOOKSTORE/loyalty/terminal` |
|
||||||
| Cards | `http://localhost:9999/platforms/loyalty/store/BOOKSTORE/loyalty/cards` |
|
| Cards | `http://localhost:8000/platforms/loyalty/store/BOOKSTORE/loyalty/cards` |
|
||||||
| Settings | `http://localhost:9999/platforms/loyalty/store/BOOKSTORE/loyalty/settings` |
|
| Settings | `http://localhost:8000/platforms/loyalty/store/BOOKSTORE/loyalty/settings` |
|
||||||
| Stats | `http://localhost:9999/platforms/loyalty/store/BOOKSTORE/loyalty/stats` |
|
| Stats | `http://localhost:8000/platforms/loyalty/store/BOOKSTORE/loyalty/stats` |
|
||||||
| Enroll Customer | `http://localhost:9999/platforms/loyalty/store/BOOKSTORE/loyalty/enroll` |
|
| Enroll Customer | `http://localhost:8000/platforms/loyalty/store/BOOKSTORE/loyalty/enroll` |
|
||||||
|
|
||||||
### 3. Customer Storefront Pages
|
### 3. Customer Storefront Pages
|
||||||
|
|
||||||
Login as a customer (e.g., `customer1@orion.example.com`).
|
Login as a customer (e.g., `customer1@orion.example.com`).
|
||||||
|
|
||||||
!!! note "Store domain required"
|
!!! note "Store code required in dev"
|
||||||
Storefront pages require a store domain context. Only ORION (`orion.shop`)
|
Storefront pages in dev require the store code in the URL path:
|
||||||
and FASHIONHUB (`fashionhub.store`) have domains configured. In dev, storefront
|
`/platforms/loyalty/storefront/{STORE_CODE}/...`. In production, the store
|
||||||
routes may need to be accessed through the store's domain or platform path.
|
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` |
|
| Loyalty Dashboard | `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/account/loyalty` |
|
||||||
| Transaction History | `http://localhost:9999/platforms/loyalty/account/loyalty/history` |
|
| Transaction History | `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/account/loyalty/history` |
|
||||||
|
|
||||||
### 4. Public Pages (No Auth)
|
### 4. Public Pages (No Auth)
|
||||||
|
|
||||||
| Page | Dev URL |
|
| Page | Dev URL (FASHIONHUB example) |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| Self-Enrollment | `http://localhost:9999/platforms/loyalty/loyalty/join` |
|
| Self-Enrollment | `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/loyalty/join` |
|
||||||
| Enrollment Success | `http://localhost:9999/platforms/loyalty/loyalty/join/success` |
|
| Enrollment Success | `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/loyalty/join/success` |
|
||||||
|
|
||||||
### 5. API Endpoints
|
### 5. API Endpoints
|
||||||
|
|
||||||
**Admin API** (prefix: `/platforms/loyalty/api/admin/loyalty/`):
|
**Admin API** (prefix: `/platforms/loyalty/api/v1/admin/loyalty/`):
|
||||||
|
|
||||||
| Method | Dev URL |
|
| Method | Dev URL |
|
||||||
|--------|---------|
|
|--------|---------|
|
||||||
| GET | `http://localhost:9999/platforms/loyalty/api/admin/loyalty/programs` |
|
| GET | `http://localhost:8000/platforms/loyalty/api/v1/admin/loyalty/programs` |
|
||||||
| GET | `http://localhost:9999/platforms/loyalty/api/admin/loyalty/stats` |
|
| 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 |
|
| Method | Endpoint | Dev URL |
|
||||||
|--------|----------|---------|
|
|--------|----------|---------|
|
||||||
| GET | program | `http://localhost:9999/platforms/loyalty/api/store/loyalty/program` |
|
| GET | program | `http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/program` |
|
||||||
| POST | program | `http://localhost:9999/platforms/loyalty/api/store/loyalty/program` |
|
| POST | program | `http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/program` |
|
||||||
| POST | stamp | `http://localhost:9999/platforms/loyalty/api/store/loyalty/stamp` |
|
| POST | stamp | `http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/stamp` |
|
||||||
| POST | points | `http://localhost:9999/platforms/loyalty/api/store/loyalty/points` |
|
| POST | points | `http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/points` |
|
||||||
| POST | enroll | `http://localhost:9999/platforms/loyalty/api/store/loyalty/cards/enroll` |
|
| POST | enroll | `http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/cards/enroll` |
|
||||||
| POST | lookup | `http://localhost:9999/platforms/loyalty/api/store/loyalty/cards/lookup` |
|
| 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 |
|
| Method | Endpoint | Dev URL |
|
||||||
|--------|----------|---------|
|
|--------|----------|---------|
|
||||||
| GET | program | `http://localhost:9999/platforms/loyalty/api/storefront/loyalty/program` |
|
| GET | program | `http://localhost:8000/platforms/loyalty/api/v1/storefront/loyalty/program` |
|
||||||
| POST | enroll | `http://localhost:9999/platforms/loyalty/api/storefront/loyalty/enroll` |
|
| POST | enroll | `http://localhost:8000/platforms/loyalty/api/v1/storefront/loyalty/enroll` |
|
||||||
| GET | card | `http://localhost:9999/platforms/loyalty/api/storefront/loyalty/card` |
|
| GET | card | `http://localhost:8000/platforms/loyalty/api/v1/storefront/loyalty/card` |
|
||||||
| GET | transactions | `http://localhost:9999/platforms/loyalty/api/storefront/loyalty/transactions` |
|
| 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 |
|
| 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 |
|
| Method | Production URL |
|
||||||
|--------|----------------|
|
|--------|----------------|
|
||||||
| GET card | `https://orion.shop/api/storefront/loyalty/card` |
|
| GET card | `https://orion.shop/api/v1/storefront/loyalty/card` |
|
||||||
| GET transactions | `https://orion.shop/api/storefront/loyalty/transactions` |
|
| GET transactions | `https://orion.shop/api/v1/storefront/loyalty/transactions` |
|
||||||
| POST enroll | `https://orion.shop/api/storefront/loyalty/enroll` |
|
| POST enroll | `https://orion.shop/api/v1/storefront/loyalty/enroll` |
|
||||||
| GET program | `https://orion.shop/api/storefront/loyalty/program` |
|
| GET program | `https://orion.shop/api/v1/storefront/loyalty/program` |
|
||||||
|
|
||||||
**Store backend (staff/owner):**
|
**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 |
|
| Method | Production URL |
|
||||||
|--------|----------------|
|
|--------|----------------|
|
||||||
| GET program | `https://orion.shop/api/store/loyalty/program` |
|
| GET program | `https://orion.shop/api/v1/store/loyalty/program` |
|
||||||
| POST program | `https://orion.shop/api/store/loyalty/program` |
|
| POST program | `https://orion.shop/api/v1/store/loyalty/program` |
|
||||||
| POST stamp | `https://orion.shop/api/store/loyalty/stamp` |
|
| POST stamp | `https://orion.shop/api/v1/store/loyalty/stamp` |
|
||||||
| POST points | `https://orion.shop/api/store/loyalty/points` |
|
| POST points | `https://orion.shop/api/v1/store/loyalty/points` |
|
||||||
| POST enroll | `https://orion.shop/api/store/loyalty/cards/enroll` |
|
| POST enroll | `https://orion.shop/api/v1/store/loyalty/cards/enroll` |
|
||||||
| POST lookup | `https://orion.shop/api/store/loyalty/cards/lookup` |
|
| POST lookup | `https://orion.shop/api/v1/store/loyalty/cards/lookup` |
|
||||||
|
|
||||||
### Case 2: Store with merchant domain (e.g., `myloyaltyprogram.lu`)
|
### 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 |
|
| Method | Production URL |
|
||||||
|--------|----------------|
|
|--------|----------------|
|
||||||
| GET card | `https://myloyaltyprogram.lu/api/storefront/loyalty/card` |
|
| GET card | `https://myloyaltyprogram.lu/api/v1/storefront/loyalty/card` |
|
||||||
| GET transactions | `https://myloyaltyprogram.lu/api/storefront/loyalty/transactions` |
|
| GET transactions | `https://myloyaltyprogram.lu/api/v1/storefront/loyalty/transactions` |
|
||||||
| POST enroll | `https://myloyaltyprogram.lu/api/storefront/loyalty/enroll` |
|
| POST enroll | `https://myloyaltyprogram.lu/api/v1/storefront/loyalty/enroll` |
|
||||||
| GET program | `https://myloyaltyprogram.lu/api/storefront/loyalty/program` |
|
| GET program | `https://myloyaltyprogram.lu/api/v1/storefront/loyalty/program` |
|
||||||
|
|
||||||
**Store backend (staff/owner):**
|
**Store backend (staff/owner):**
|
||||||
|
|
||||||
@@ -280,11 +280,11 @@ store when the URL includes `/store/{store_code}/...`.
|
|||||||
|
|
||||||
| Method | Production URL |
|
| Method | Production URL |
|
||||||
|--------|----------------|
|
|--------|----------------|
|
||||||
| GET program | `https://myloyaltyprogram.lu/api/store/loyalty/program` |
|
| GET program | `https://myloyaltyprogram.lu/api/v1/store/loyalty/program` |
|
||||||
| POST stamp | `https://myloyaltyprogram.lu/api/store/loyalty/stamp` |
|
| POST stamp | `https://myloyaltyprogram.lu/api/v1/store/loyalty/stamp` |
|
||||||
| POST points | `https://myloyaltyprogram.lu/api/store/loyalty/points` |
|
| POST points | `https://myloyaltyprogram.lu/api/v1/store/loyalty/points` |
|
||||||
| POST enroll | `https://myloyaltyprogram.lu/api/store/loyalty/cards/enroll` |
|
| POST enroll | `https://myloyaltyprogram.lu/api/v1/store/loyalty/cards/enroll` |
|
||||||
| POST lookup | `https://myloyaltyprogram.lu/api/store/loyalty/cards/lookup` |
|
| POST lookup | `https://myloyaltyprogram.lu/api/v1/store/loyalty/cards/lookup` |
|
||||||
|
|
||||||
!!! note "Merchant domain resolves to first active store"
|
!!! note "Merchant domain resolves to first active store"
|
||||||
When a customer visits `myloyaltyprogram.lu` without a `/store/{code}/...` path,
|
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 |
|
| Method | Production URL |
|
||||||
|--------|----------------|
|
|--------|----------------|
|
||||||
| GET card | `https://bookstore.rewardflow.lu/api/storefront/loyalty/card` |
|
| GET card | `https://bookstore.rewardflow.lu/api/v1/storefront/loyalty/card` |
|
||||||
| GET transactions | `https://bookstore.rewardflow.lu/api/storefront/loyalty/transactions` |
|
| GET transactions | `https://bookstore.rewardflow.lu/api/v1/storefront/loyalty/transactions` |
|
||||||
| POST enroll | `https://bookstore.rewardflow.lu/api/storefront/loyalty/enroll` |
|
| POST enroll | `https://bookstore.rewardflow.lu/api/v1/storefront/loyalty/enroll` |
|
||||||
| GET program | `https://bookstore.rewardflow.lu/api/storefront/loyalty/program` |
|
| GET program | `https://bookstore.rewardflow.lu/api/v1/storefront/loyalty/program` |
|
||||||
|
|
||||||
**Store backend (staff/owner):**
|
**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 |
|
| Method | Production URL |
|
||||||
|--------|----------------|
|
|--------|----------------|
|
||||||
| GET program | `https://bookstore.rewardflow.lu/api/store/loyalty/program` |
|
| GET program | `https://bookstore.rewardflow.lu/api/v1/store/loyalty/program` |
|
||||||
| POST stamp | `https://bookstore.rewardflow.lu/api/store/loyalty/stamp` |
|
| POST stamp | `https://bookstore.rewardflow.lu/api/v1/store/loyalty/stamp` |
|
||||||
| POST points | `https://bookstore.rewardflow.lu/api/store/loyalty/points` |
|
| POST points | `https://bookstore.rewardflow.lu/api/v1/store/loyalty/points` |
|
||||||
| POST enroll | `https://bookstore.rewardflow.lu/api/store/loyalty/cards/enroll` |
|
| POST enroll | `https://bookstore.rewardflow.lu/api/v1/store/loyalty/cards/enroll` |
|
||||||
| POST lookup | `https://bookstore.rewardflow.lu/api/store/loyalty/cards/lookup` |
|
| POST lookup | `https://bookstore.rewardflow.lu/api/v1/store/loyalty/cards/lookup` |
|
||||||
|
|
||||||
### Platform Admin & Public API (always on platform domain)
|
### 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 Analytics | `https://rewardflow.lu/admin/loyalty/analytics` |
|
||||||
| Admin Merchant Detail | `https://rewardflow.lu/admin/loyalty/merchants/{id}` |
|
| Admin Merchant Detail | `https://rewardflow.lu/admin/loyalty/merchants/{id}` |
|
||||||
| Admin Merchant Settings | `https://rewardflow.lu/admin/loyalty/merchants/{id}/settings` |
|
| Admin Merchant Settings | `https://rewardflow.lu/admin/loyalty/merchants/{id}/settings` |
|
||||||
| Admin API - Programs | `GET https://rewardflow.lu/api/admin/loyalty/programs` |
|
| Admin API - Programs | `GET https://rewardflow.lu/api/v1/admin/loyalty/programs` |
|
||||||
| Admin API - Stats | `GET https://rewardflow.lu/api/admin/loyalty/stats` |
|
| Admin API - Stats | `GET https://rewardflow.lu/api/v1/admin/loyalty/stats` |
|
||||||
| Public API - Program | `GET https://rewardflow.lu/api/loyalty/programs/ORION` |
|
| Public API - Program | `GET https://rewardflow.lu/api/v1/loyalty/programs/ORION` |
|
||||||
| Apple Wallet Pass | `GET https://rewardflow.lu/api/loyalty/passes/apple/{serial}.pkpass` |
|
| Apple Wallet Pass | `GET https://rewardflow.lu/api/v1/loyalty/passes/apple/{serial}.pkpass` |
|
||||||
|
|
||||||
### Domain configuration per store (current DB state)
|
### Domain configuration per store (current DB state)
|
||||||
|
|
||||||
@@ -418,19 +418,19 @@ flowchart TD
|
|||||||
**Step 1: Subscribe to the platform**
|
**Step 1: Subscribe to the platform**
|
||||||
|
|
||||||
1. Login as `john.owner@wizacorp.com` and navigate to billing:
|
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 (custom domain): `https://orion.shop/store/ORION/billing`
|
||||||
- Prod (subdomain): `https://orion.rewardflow.lu/store/ORION/billing`
|
- Prod (subdomain): `https://orion.rewardflow.lu/store/ORION/billing`
|
||||||
2. View available subscription tiers:
|
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`
|
- API Prod: `GET https://{store_domain}/api/v1/store/billing/tiers`
|
||||||
3. Select a tier and initiate Stripe checkout:
|
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`
|
- API Prod: `POST https://{store_domain}/api/v1/store/billing/checkout`
|
||||||
4. Complete payment on Stripe checkout page
|
4. Complete payment on Stripe checkout page
|
||||||
5. Webhook `checkout.session.completed` activates the subscription
|
5. Webhook `checkout.session.completed` activates the subscription
|
||||||
6. Verify subscription is active:
|
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`
|
- API Prod: `GET https://{store_domain}/api/v1/store/billing/subscription`
|
||||||
|
|
||||||
**Step 2: Register merchant domain (admin action)**
|
**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.
|
registers the domain on behalf of the merchant via the admin API.
|
||||||
|
|
||||||
1. Platform admin registers a merchant domain:
|
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`
|
- API Prod: `POST https://rewardflow.lu/api/v1/admin/merchants/{merchant_id}/domains`
|
||||||
- Body: `{"domain": "myloyaltyprogram.lu", "is_primary": true}`
|
- Body: `{"domain": "myloyaltyprogram.lu", "is_primary": true}`
|
||||||
2. The API returns a `verification_token` for DNS verification
|
2. The API returns a `verification_token` for DNS verification
|
||||||
3. Get DNS verification instructions:
|
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`
|
- 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}`
|
4. Merchant adds a DNS TXT record: `_orion-verify.myloyaltyprogram.lu TXT {verification_token}`
|
||||||
5. Verify the domain:
|
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`
|
- API Prod: `POST https://rewardflow.lu/api/v1/admin/merchants/domains/merchant/{domain_id}/verify`
|
||||||
6. Activate the domain:
|
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}`
|
- API Prod: `PUT https://rewardflow.lu/api/v1/admin/merchants/domains/merchant/{domain_id}`
|
||||||
- Body: `{"is_active": true}`
|
- Body: `{"is_active": true}`
|
||||||
7. All merchant stores now inherit `myloyaltyprogram.lu` as their effective domain
|
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`):
|
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:
|
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`
|
- API Prod: `POST https://rewardflow.lu/api/v1/admin/stores/{store_id}/domains`
|
||||||
- Body: `{"domain": "mysuperloyaltyprogram.lu", "is_primary": true}`
|
- Body: `{"domain": "mysuperloyaltyprogram.lu", "is_primary": true}`
|
||||||
2. Follow the same DNS verification and activation flow as merchant domains
|
2. Follow the same DNS verification and activation flow as merchant domains
|
||||||
@@ -507,25 +507,25 @@ flowchart TD
|
|||||||
**Steps:**
|
**Steps:**
|
||||||
|
|
||||||
1. Login as `john.owner@wizacorp.com` at:
|
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 (custom domain): `https://orion.shop/store/ORION/login`
|
||||||
- Prod (subdomain): `https://orion.rewardflow.lu/store/ORION/login`
|
- Prod (subdomain): `https://orion.rewardflow.lu/store/ORION/login`
|
||||||
2. Navigate to loyalty settings:
|
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 (custom domain): `https://orion.shop/store/ORION/loyalty/settings`
|
||||||
- Prod (subdomain): `https://orion.rewardflow.lu/store/ORION/loyalty/settings`
|
- Prod (subdomain): `https://orion.rewardflow.lu/store/ORION/loyalty/settings`
|
||||||
3. Create a new loyalty program:
|
3. Create a new loyalty program:
|
||||||
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/program`
|
- Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/program`
|
||||||
- Prod: `POST https://{store_domain}/api/store/loyalty/program`
|
- Prod: `POST https://{store_domain}/api/v1/store/loyalty/program`
|
||||||
4. Choose loyalty type (stamps, points, or hybrid)
|
4. Choose loyalty type (stamps, points, or hybrid)
|
||||||
5. Configure program parameters (stamp target, points-per-euro, rewards)
|
5. Configure program parameters (stamp target, points-per-euro, rewards)
|
||||||
6. Set branding (card color, logo, hero image)
|
6. Set branding (card color, logo, hero image)
|
||||||
7. Configure anti-fraud (cooldown, daily limits, PIN requirements)
|
7. Configure anti-fraud (cooldown, daily limits, PIN requirements)
|
||||||
8. Create staff PINs:
|
8. Create staff PINs:
|
||||||
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/pins`
|
- Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/pins`
|
||||||
- Prod: `POST https://{store_domain}/api/store/loyalty/pins`
|
- Prod: `POST https://{store_domain}/api/v1/store/loyalty/pins`
|
||||||
9. Verify program is live - check from another store (same merchant):
|
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`
|
- Prod (subdomain): `https://wizagadgets.rewardflow.lu/store/WIZAGADGETS/loyalty/settings`
|
||||||
|
|
||||||
**Expected blockers in current state:**
|
**Expected blockers in current state:**
|
||||||
@@ -563,23 +563,23 @@ flowchart TD
|
|||||||
**Steps:**
|
**Steps:**
|
||||||
|
|
||||||
1. Login as `alice.manager@wizacorp.com` and open the terminal:
|
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`
|
- Prod: `https://{store_domain}/store/ORION/loyalty/terminal`
|
||||||
2. Scan customer QR code or enter card number:
|
2. Scan customer QR code or enter card number:
|
||||||
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/cards/lookup`
|
- Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/cards/lookup`
|
||||||
- Prod: `POST https://{store_domain}/api/store/loyalty/cards/lookup`
|
- Prod: `POST https://{store_domain}/api/v1/store/loyalty/cards/lookup`
|
||||||
3. Enter staff PIN for verification
|
3. Enter staff PIN for verification
|
||||||
4. Add stamp:
|
4. Add stamp:
|
||||||
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/stamp`
|
- Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/stamp`
|
||||||
- Prod: `POST https://{store_domain}/api/store/loyalty/stamp`
|
- Prod: `POST https://{store_domain}/api/v1/store/loyalty/stamp`
|
||||||
5. If target reached, redeem reward:
|
5. If target reached, redeem reward:
|
||||||
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/stamp/redeem`
|
- Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/stamp/redeem`
|
||||||
- Prod: `POST https://{store_domain}/api/store/loyalty/stamp/redeem`
|
- Prod: `POST https://{store_domain}/api/v1/store/loyalty/stamp/redeem`
|
||||||
6. View updated card:
|
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}`
|
- Prod: `https://{store_domain}/store/ORION/loyalty/cards/{card_id}`
|
||||||
7. Browse all cards:
|
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`
|
- Prod: `https://{store_domain}/store/ORION/loyalty/cards`
|
||||||
|
|
||||||
**Anti-fraud scenarios to test:**
|
**Anti-fraud scenarios to test:**
|
||||||
@@ -611,20 +611,20 @@ flowchart TD
|
|||||||
**Steps:**
|
**Steps:**
|
||||||
|
|
||||||
1. Open the terminal:
|
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`
|
- Prod: `https://{store_domain}/store/ORION/loyalty/terminal`
|
||||||
2. Lookup card:
|
2. Lookup card:
|
||||||
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/cards/lookup`
|
- Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/cards/lookup`
|
||||||
- Prod: `POST https://{store_domain}/api/store/loyalty/cards/lookup`
|
- Prod: `POST https://{store_domain}/api/v1/store/loyalty/cards/lookup`
|
||||||
3. Enter purchase amount (e.g., 25.00 EUR)
|
3. Enter purchase amount (e.g., 25.00 EUR)
|
||||||
4. Earn points (auto-calculated at 10 pts/EUR = 250 points):
|
4. Earn points (auto-calculated at 10 pts/EUR = 250 points):
|
||||||
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/points`
|
- Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/points`
|
||||||
- Prod: `POST https://{store_domain}/api/store/loyalty/points`
|
- Prod: `POST https://{store_domain}/api/v1/store/loyalty/points`
|
||||||
5. If enough balance, redeem points for reward:
|
5. If enough balance, redeem points for reward:
|
||||||
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/points/redeem`
|
- Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/points/redeem`
|
||||||
- Prod: `POST https://{store_domain}/api/store/loyalty/points/redeem`
|
- Prod: `POST https://{store_domain}/api/v1/store/loyalty/points/redeem`
|
||||||
6. Check store-level stats:
|
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`
|
- Prod: `https://{store_domain}/store/ORION/loyalty/stats`
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -647,21 +647,21 @@ flowchart TD
|
|||||||
**Steps:**
|
**Steps:**
|
||||||
|
|
||||||
1. Visit the public enrollment page:
|
1. Visit the public enrollment page:
|
||||||
- Dev: `http://localhost:9999/platforms/loyalty/loyalty/join`
|
- Dev: `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/loyalty/join`
|
||||||
- Prod (custom domain): `https://orion.shop/loyalty/join`
|
- Prod (custom domain): `https://fashionhub.store/loyalty/join`
|
||||||
- Prod (subdomain): `https://bookstore.rewardflow.lu/loyalty/join`
|
- Prod (subdomain): `https://bookstore.rewardflow.lu/loyalty/join`
|
||||||
2. Fill in enrollment form (email, name)
|
2. Fill in enrollment form (email, name)
|
||||||
3. Submit enrollment:
|
3. Submit enrollment:
|
||||||
- Dev: `POST http://localhost:9999/platforms/loyalty/api/storefront/loyalty/enroll`
|
- Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/storefront/loyalty/enroll`
|
||||||
- Prod (custom domain): `POST https://orion.shop/api/storefront/loyalty/enroll`
|
- Prod (custom domain): `POST https://fashionhub.store/api/v1/storefront/loyalty/enroll`
|
||||||
- Prod (subdomain): `POST https://bookstore.rewardflow.lu/api/storefront/loyalty/enroll`
|
- Prod (subdomain): `POST https://bookstore.rewardflow.lu/api/v1/storefront/loyalty/enroll`
|
||||||
4. Redirected to success page:
|
4. Redirected to success page:
|
||||||
- Dev: `http://localhost:9999/platforms/loyalty/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://orion.shop/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`
|
- Prod (subdomain): `https://bookstore.rewardflow.lu/loyalty/join/success?card=XXXX-XXXX-XXXX`
|
||||||
5. Optionally download Apple Wallet pass:
|
5. Optionally download Apple Wallet pass:
|
||||||
- Dev: `GET http://localhost:9999/platforms/loyalty/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/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
|
1. Login as customer at the storefront
|
||||||
2. View loyalty dashboard (card balance, available rewards):
|
2. View loyalty dashboard (card balance, available rewards):
|
||||||
- Dev: `http://localhost:9999/platforms/loyalty/account/loyalty`
|
- Dev: `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/account/loyalty`
|
||||||
- Prod (custom domain): `https://orion.shop/account/loyalty`
|
- Prod (custom domain): `https://fashionhub.store/account/loyalty`
|
||||||
- Prod (subdomain): `https://bookstore.rewardflow.lu/account/loyalty`
|
- Prod (subdomain): `https://bookstore.rewardflow.lu/account/loyalty`
|
||||||
- API Dev: `GET http://localhost:9999/platforms/loyalty/api/storefront/loyalty/card`
|
- API Dev: `GET http://localhost:8000/platforms/loyalty/api/v1/storefront/loyalty/card`
|
||||||
- API Prod: `GET https://orion.shop/api/storefront/loyalty/card`
|
- API Prod: `GET https://fashionhub.store/api/v1/storefront/loyalty/card`
|
||||||
3. View full transaction history:
|
3. View full transaction history:
|
||||||
- Dev: `http://localhost:9999/platforms/loyalty/account/loyalty/history`
|
- Dev: `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/account/loyalty/history`
|
||||||
- Prod (custom domain): `https://orion.shop/account/loyalty/history`
|
- Prod (custom domain): `https://fashionhub.store/account/loyalty/history`
|
||||||
- Prod (subdomain): `https://bookstore.rewardflow.lu/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 Dev: `GET http://localhost:8000/platforms/loyalty/api/v1/storefront/loyalty/transactions`
|
||||||
- API Prod: `GET https://orion.shop/api/storefront/loyalty/transactions`
|
- API Prod: `GET https://fashionhub.store/api/v1/storefront/loyalty/transactions`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -697,22 +697,22 @@ flowchart TD
|
|||||||
|
|
||||||
1. Login as admin
|
1. Login as admin
|
||||||
2. View all programs:
|
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`
|
- Prod: `https://rewardflow.lu/admin/loyalty/programs`
|
||||||
3. View platform-wide analytics:
|
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`
|
- Prod: `https://rewardflow.lu/admin/loyalty/analytics`
|
||||||
4. Drill into WizaCorp's program:
|
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`
|
- Prod: `https://rewardflow.lu/admin/loyalty/merchants/1`
|
||||||
5. Manage WizaCorp's merchant-level settings:
|
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`
|
- Prod: `https://rewardflow.lu/admin/loyalty/merchants/1/settings`
|
||||||
- API Dev: `PATCH http://localhost:9999/platforms/loyalty/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/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
|
6. Adjust settings: PIN policy, self-enrollment toggle, void permissions
|
||||||
7. Check other merchants:
|
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`
|
- Prod: `https://rewardflow.lu/admin/loyalty/merchants/2`
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -725,21 +725,21 @@ flowchart TD
|
|||||||
**Steps:**
|
**Steps:**
|
||||||
|
|
||||||
1. Open terminal and lookup card:
|
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`
|
- Prod: `https://{store_domain}/store/ORION/loyalty/terminal`
|
||||||
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/cards/lookup`
|
- Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/cards/lookup`
|
||||||
- Prod: `POST https://{store_domain}/api/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:
|
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}`
|
- 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 Dev: `GET http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/cards/{card_id}/transactions`
|
||||||
- API Prod: `GET https://{store_domain}/api/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:
|
3. Void a stamp transaction:
|
||||||
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/stamp/void`
|
- Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/stamp/void`
|
||||||
- Prod: `POST https://{store_domain}/api/store/loyalty/stamp/void`
|
- Prod: `POST https://{store_domain}/api/v1/store/loyalty/stamp/void`
|
||||||
4. Or void a points transaction:
|
4. Or void a points transaction:
|
||||||
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/points/void`
|
- Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/points/void`
|
||||||
- Prod: `POST https://{store_domain}/api/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
|
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:
|
**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`
|
- Prod: `https://rewardflow.lu/admin/loyalty/merchants/1/settings`
|
||||||
|
|
||||||
**Steps:**
|
**Steps:**
|
||||||
|
|
||||||
1. Staff at ORION adds stamps to customer's card:
|
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`
|
- Prod: `https://{store_domain}/store/ORION/loyalty/terminal`
|
||||||
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/stamp`
|
- Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/stamp`
|
||||||
- Prod: `POST https://{store_domain}/api/store/loyalty/stamp`
|
- Prod: `POST https://{store_domain}/api/v1/store/loyalty/stamp`
|
||||||
2. Customer visits WIZAGADGETS
|
2. Customer visits WIZAGADGETS
|
||||||
3. Staff at WIZAGADGETS looks up the same card:
|
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`
|
- Prod: `https://{store_domain}/store/WIZAGADGETS/loyalty/terminal`
|
||||||
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/cards/lookup`
|
- Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/cards/lookup`
|
||||||
- Prod: `POST https://{store_domain}/api/store/loyalty/cards/lookup`
|
- Prod: `POST https://{store_domain}/api/v1/store/loyalty/cards/lookup`
|
||||||
4. Card is found (same merchant) with accumulated stamps
|
4. Card is found (same merchant) with accumulated stamps
|
||||||
5. Staff at WIZAGADGETS redeems the reward:
|
5. Staff at WIZAGADGETS redeems the reward:
|
||||||
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/stamp/redeem`
|
- Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/stamp/redeem`
|
||||||
- Prod: `POST https://{store_domain}/api/store/loyalty/stamp/redeem`
|
- Prod: `POST https://{store_domain}/api/v1/store/loyalty/stamp/redeem`
|
||||||
6. Verify transaction history shows both stores:
|
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}`
|
- Prod: `https://{store_domain}/store/WIZAGADGETS/loyalty/cards/{card_id}`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -259,3 +259,17 @@ def get_platform_stats(
|
|||||||
):
|
):
|
||||||
"""Get platform-wide loyalty statistics."""
|
"""Get platform-wide loyalty statistics."""
|
||||||
return program_service.get_platform_stats(db)
|
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
|
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
|
# Auth Verification
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -628,18 +656,71 @@ class AppleWalletService:
|
|||||||
"""
|
"""
|
||||||
Send an empty push notification to trigger pass update.
|
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
|
if not self.is_configured:
|
||||||
# For now, we'll log and skip the actual push
|
logger.debug("Apple Wallet not configured, skipping push")
|
||||||
logger.debug(f"Would send push to token {push_token[:8]}...")
|
return
|
||||||
|
|
||||||
# In production, you would use something like:
|
import ssl
|
||||||
# from apns2.client import APNsClient
|
|
||||||
# from apns2.payload import Payload
|
import httpx
|
||||||
# client = APNsClient(config.apple_signer_cert_path, use_sandbox=True)
|
|
||||||
# payload = Payload()
|
# APNs endpoint (use sandbox for dev, production for prod)
|
||||||
# client.send_notification(push_token, payload, "pass.com.example.loyalty")
|
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
|
# Singleton instance
|
||||||
|
|||||||
@@ -7,9 +7,14 @@ Handles Google Wallet integration including:
|
|||||||
- Creating LoyaltyObject for cards
|
- Creating LoyaltyObject for cards
|
||||||
- Updating objects on balance changes
|
- Updating objects on balance changes
|
||||||
- Generating "Add to Wallet" URLs
|
- Generating "Add to Wallet" URLs
|
||||||
|
- Startup config validation
|
||||||
|
- Retry logic for transient API failures
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -23,6 +28,51 @@ from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
class GoogleWalletService:
|
||||||
"""Service for Google Wallet integration."""
|
"""Service for Google Wallet integration."""
|
||||||
@@ -31,11 +81,70 @@ class GoogleWalletService:
|
|||||||
"""Initialize the Google Wallet service."""
|
"""Initialize the Google Wallet service."""
|
||||||
self._credentials = None
|
self._credentials = None
|
||||||
self._http_client = None
|
self._http_client = None
|
||||||
|
self._signer = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_configured(self) -> bool:
|
def is_configured(self) -> bool:
|
||||||
"""Check if Google Wallet is configured."""
|
"""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):
|
def _get_credentials(self):
|
||||||
"""Get Google service account credentials."""
|
"""Get Google service account credentials."""
|
||||||
@@ -50,14 +159,32 @@ class GoogleWalletService:
|
|||||||
|
|
||||||
scopes = ["https://www.googleapis.com/auth/wallet_object.issuer"]
|
scopes = ["https://www.googleapis.com/auth/wallet_object.issuer"]
|
||||||
|
|
||||||
self._credentials = service_account.Credentials.from_service_account_file(
|
self._credentials = (
|
||||||
settings.loyalty_google_service_account_json,
|
service_account.Credentials.from_service_account_file(
|
||||||
scopes=scopes,
|
settings.loyalty_google_service_account_json,
|
||||||
|
scopes=scopes,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return self._credentials
|
return self._credentials
|
||||||
except (ValueError, OSError) as e:
|
except (ValueError, OSError) as exc:
|
||||||
logger.error(f"Failed to load Google credentials: {e}")
|
logger.error("Failed to load Google credentials: %s", exc)
|
||||||
raise WalletIntegrationException("google", str(e))
|
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):
|
def _get_http_client(self):
|
||||||
"""Get authenticated HTTP client."""
|
"""Get authenticated HTTP client."""
|
||||||
@@ -70,14 +197,15 @@ class GoogleWalletService:
|
|||||||
credentials = self._get_credentials()
|
credentials = self._get_credentials()
|
||||||
self._http_client = AuthorizedSession(credentials)
|
self._http_client = AuthorizedSession(credentials)
|
||||||
return self._http_client
|
return self._http_client
|
||||||
except Exception as e: # noqa: EXC003
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.error(f"Failed to create Google HTTP client: {e}")
|
logger.error("Failed to create Google HTTP client: %s", exc)
|
||||||
raise WalletIntegrationException("google", str(e))
|
raise WalletIntegrationException("google", str(exc))
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# LoyaltyClass Operations (Program-level)
|
# LoyaltyClass Operations (Program-level)
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
|
@_retry_on_failure
|
||||||
def create_class(self, db: Session, program: LoyaltyProgram) -> str:
|
def create_class(self, db: Session, program: LoyaltyProgram) -> str:
|
||||||
"""
|
"""
|
||||||
Create a LoyaltyClass for a loyalty program.
|
Create a LoyaltyClass for a loyalty program.
|
||||||
@@ -95,17 +223,16 @@ class GoogleWalletService:
|
|||||||
issuer_id = settings.loyalty_google_issuer_id
|
issuer_id = settings.loyalty_google_issuer_id
|
||||||
class_id = f"{issuer_id}.loyalty_program_{program.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 = {
|
class_data = {
|
||||||
"id": class_id,
|
"id": class_id,
|
||||||
"issuerId": issuer_id,
|
"issuerId": issuer_id,
|
||||||
"reviewStatus": "UNDER_REVIEW",
|
"issuerName": issuer_name,
|
||||||
|
"reviewStatus": "DRAFT",
|
||||||
"programName": program.display_name,
|
"programName": program.display_name,
|
||||||
"programLogo": {
|
"hexBackgroundColor": program.card_color or "#4285F4",
|
||||||
"sourceUri": {
|
|
||||||
"uri": program.logo_url or "https://via.placeholder.com/100",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"hexBackgroundColor": program.card_color,
|
|
||||||
"localizedProgramName": {
|
"localizedProgramName": {
|
||||||
"defaultValue": {
|
"defaultValue": {
|
||||||
"language": "en",
|
"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
|
# Add hero image if configured
|
||||||
if program.hero_image_url:
|
if program.hero_image_url:
|
||||||
class_data["heroImage"] = {
|
class_data["heroImage"] = {
|
||||||
@@ -128,14 +264,15 @@ class GoogleWalletService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code in (200, 201):
|
if response.status_code in (200, 201):
|
||||||
# Update program with class ID
|
|
||||||
program.google_class_id = class_id
|
program.google_class_id = class_id
|
||||||
db.commit()
|
db.commit()
|
||||||
|
logger.info(
|
||||||
logger.info(f"Created Google Wallet class {class_id} for program {program.id}")
|
"Created Google Wallet class %s for program %s",
|
||||||
|
class_id,
|
||||||
|
program.id,
|
||||||
|
)
|
||||||
return class_id
|
return class_id
|
||||||
if response.status_code == 409:
|
if response.status_code == 409:
|
||||||
# Class already exists
|
|
||||||
program.google_class_id = class_id
|
program.google_class_id = class_id
|
||||||
db.commit()
|
db.commit()
|
||||||
return class_id
|
return class_id
|
||||||
@@ -146,10 +283,11 @@ class GoogleWalletService:
|
|||||||
)
|
)
|
||||||
except WalletIntegrationException:
|
except WalletIntegrationException:
|
||||||
raise
|
raise
|
||||||
except Exception as e: # noqa: EXC003
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.error(f"Failed to create Google Wallet class: {e}")
|
logger.error("Failed to create Google Wallet class: %s", exc)
|
||||||
raise WalletIntegrationException("google", str(e))
|
raise WalletIntegrationException("google", str(exc))
|
||||||
|
|
||||||
|
@_retry_on_failure
|
||||||
def update_class(self, db: Session, program: LoyaltyProgram) -> None:
|
def update_class(self, db: Session, program: LoyaltyProgram) -> None:
|
||||||
"""Update a LoyaltyClass when program settings change."""
|
"""Update a LoyaltyClass when program settings change."""
|
||||||
if not program.google_class_id:
|
if not program.google_class_id:
|
||||||
@@ -168,22 +306,25 @@ class GoogleWalletService:
|
|||||||
try:
|
try:
|
||||||
http = self._get_http_client()
|
http = self._get_http_client()
|
||||||
response = http.patch(
|
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,
|
json=class_data,
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code not in (200, 201):
|
if response.status_code not in (200, 201):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Failed to update Google Wallet class {program.google_class_id}: "
|
"Failed to update Google Wallet class %s: %s",
|
||||||
f"{response.status_code}"
|
program.google_class_id,
|
||||||
|
response.status_code,
|
||||||
)
|
)
|
||||||
except Exception as e: # noqa: EXC003
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.error(f"Failed to update Google Wallet class: {e}")
|
logger.error("Failed to update Google Wallet class: %s", exc)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# LoyaltyObject Operations (Card-level)
|
# LoyaltyObject Operations (Card-level)
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
|
@_retry_on_failure
|
||||||
def create_object(self, db: Session, card: LoyaltyCard) -> str:
|
def create_object(self, db: Session, card: LoyaltyCard) -> str:
|
||||||
"""
|
"""
|
||||||
Create a LoyaltyObject for a loyalty card.
|
Create a LoyaltyObject for a loyalty card.
|
||||||
@@ -200,7 +341,6 @@ class GoogleWalletService:
|
|||||||
|
|
||||||
program = card.program
|
program = card.program
|
||||||
if not program.google_class_id:
|
if not program.google_class_id:
|
||||||
# Create class first
|
|
||||||
self.create_class(db, program)
|
self.create_class(db, program)
|
||||||
|
|
||||||
issuer_id = settings.loyalty_google_issuer_id
|
issuer_id = settings.loyalty_google_issuer_id
|
||||||
@@ -218,11 +358,13 @@ class GoogleWalletService:
|
|||||||
if response.status_code in (200, 201):
|
if response.status_code in (200, 201):
|
||||||
card.google_object_id = object_id
|
card.google_object_id = object_id
|
||||||
db.commit()
|
db.commit()
|
||||||
|
logger.info(
|
||||||
logger.info(f"Created Google Wallet object {object_id} for card {card.id}")
|
"Created Google Wallet object %s for card %s",
|
||||||
|
object_id,
|
||||||
|
card.id,
|
||||||
|
)
|
||||||
return object_id
|
return object_id
|
||||||
if response.status_code == 409:
|
if response.status_code == 409:
|
||||||
# Object already exists
|
|
||||||
card.google_object_id = object_id
|
card.google_object_id = object_id
|
||||||
db.commit()
|
db.commit()
|
||||||
return object_id
|
return object_id
|
||||||
@@ -233,10 +375,11 @@ class GoogleWalletService:
|
|||||||
)
|
)
|
||||||
except WalletIntegrationException:
|
except WalletIntegrationException:
|
||||||
raise
|
raise
|
||||||
except Exception as e: # noqa: EXC003
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.error(f"Failed to create Google Wallet object: {e}")
|
logger.error("Failed to create Google Wallet object: %s", exc)
|
||||||
raise WalletIntegrationException("google", str(e))
|
raise WalletIntegrationException("google", str(exc))
|
||||||
|
|
||||||
|
@_retry_on_failure
|
||||||
def update_object(self, db: Session, card: LoyaltyCard) -> None:
|
def update_object(self, db: Session, card: LoyaltyCard) -> None:
|
||||||
"""Update a LoyaltyObject when card balance changes."""
|
"""Update a LoyaltyObject when card balance changes."""
|
||||||
if not card.google_object_id:
|
if not card.google_object_id:
|
||||||
@@ -247,25 +390,31 @@ class GoogleWalletService:
|
|||||||
try:
|
try:
|
||||||
http = self._get_http_client()
|
http = self._get_http_client()
|
||||||
response = http.patch(
|
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,
|
json=object_data,
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code in (200, 201):
|
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:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Failed to update Google Wallet object {card.google_object_id}: "
|
"Failed to update Google Wallet object %s: %s",
|
||||||
f"{response.status_code}"
|
card.google_object_id,
|
||||||
|
response.status_code,
|
||||||
)
|
)
|
||||||
except Exception as e: # noqa: EXC003
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.error(f"Failed to update Google Wallet object: {e}")
|
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."""
|
"""Build the LoyaltyObject data structure."""
|
||||||
program = card.program
|
program = card.program
|
||||||
|
|
||||||
object_data = {
|
object_data: dict[str, Any] = {
|
||||||
"id": object_id,
|
"id": object_id,
|
||||||
"classId": program.google_class_id,
|
"classId": program.google_class_id,
|
||||||
"state": "ACTIVE" if card.is_active else "INACTIVE",
|
"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:
|
if program.is_stamps_enabled:
|
||||||
object_data["loyaltyPoints"] = {
|
object_data["loyaltyPoints"] = {
|
||||||
"label": "Stamps",
|
"label": "Stamps",
|
||||||
@@ -286,7 +434,6 @@ class GoogleWalletService:
|
|||||||
"int": card.stamp_count,
|
"int": card.stamp_count,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
# Add secondary points showing target
|
|
||||||
object_data["secondaryLoyaltyPoints"] = {
|
object_data["secondaryLoyaltyPoints"] = {
|
||||||
"label": f"of {program.stamps_target}",
|
"label": f"of {program.stamps_target}",
|
||||||
"balance": {
|
"balance": {
|
||||||
@@ -311,6 +458,9 @@ class GoogleWalletService:
|
|||||||
"""
|
"""
|
||||||
Get the "Add to Google Wallet" URL for a card.
|
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:
|
Args:
|
||||||
db: Database session
|
db: Database session
|
||||||
card: Loyalty card
|
card: Loyalty card
|
||||||
@@ -321,34 +471,34 @@ class GoogleWalletService:
|
|||||||
if not self.is_configured:
|
if not self.is_configured:
|
||||||
raise GoogleWalletNotConfiguredException()
|
raise GoogleWalletNotConfiguredException()
|
||||||
|
|
||||||
# Ensure object exists
|
|
||||||
if not card.google_object_id:
|
if not card.google_object_id:
|
||||||
self.create_object(db, card)
|
self.create_object(db, card)
|
||||||
|
|
||||||
# Generate JWT for save link
|
|
||||||
try:
|
try:
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
|
|
||||||
credentials = self._get_credentials()
|
credentials = self._get_credentials()
|
||||||
|
signer = self._get_signer()
|
||||||
|
|
||||||
|
now = datetime.now(tz=UTC)
|
||||||
|
origins = settings.loyalty_google_wallet_origins or []
|
||||||
|
|
||||||
claims = {
|
claims = {
|
||||||
"iss": credentials.service_account_email,
|
"iss": credentials.service_account_email,
|
||||||
"aud": "google",
|
"aud": "google",
|
||||||
"origins": [],
|
"origins": origins,
|
||||||
"typ": "savetowallet",
|
"typ": "savetowallet",
|
||||||
"payload": {
|
"payload": {
|
||||||
"loyaltyObjects": [{"id": card.google_object_id}],
|
"loyaltyObjects": [{"id": card.google_object_id}],
|
||||||
},
|
},
|
||||||
"iat": datetime.utcnow(),
|
"iat": now,
|
||||||
"exp": datetime.utcnow() + timedelta(hours=1),
|
"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(
|
token = jwt.encode(
|
||||||
claims,
|
claims,
|
||||||
credentials._signer._key,
|
signer.key,
|
||||||
algorithm="RS256",
|
algorithm="RS256",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -356,9 +506,49 @@ class GoogleWalletService:
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return f"https://pay.google.com/gp/v/save/{token}"
|
return f"https://pay.google.com/gp/v/save/{token}"
|
||||||
except Exception as e: # noqa: EXC003
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.error(f"Failed to generate Google Wallet save URL: {e}")
|
logger.error(
|
||||||
raise WalletIntegrationException("google", str(e))
|
"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
|
# Singleton instance
|
||||||
|
|||||||
@@ -833,6 +833,62 @@ class ProgramService:
|
|||||||
"estimated_liability_cents": estimated_liability,
|
"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:
|
def get_merchant_stats(self, db: Session, merchant_id: int) -> dict:
|
||||||
"""
|
"""
|
||||||
Get statistics for a merchant's loyalty program across all locations.
|
Get statistics for a merchant's loyalty program across all locations.
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ function adminLoyaltyAnalytics() {
|
|||||||
showMerchantDropdown: false,
|
showMerchantDropdown: false,
|
||||||
searchingMerchants: false,
|
searchingMerchants: false,
|
||||||
|
|
||||||
|
// Wallet integration status
|
||||||
|
walletStatus: null,
|
||||||
|
walletStatusLoading: false,
|
||||||
|
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
@@ -59,6 +63,7 @@ function adminLoyaltyAnalytics() {
|
|||||||
window._loyaltyAnalyticsInitialized = true;
|
window._loyaltyAnalyticsInitialized = true;
|
||||||
|
|
||||||
await this.loadStats();
|
await this.loadStats();
|
||||||
|
await this.loadWalletStatus();
|
||||||
loyaltyAnalyticsLog.info('=== LOYALTY ANALYTICS PAGE INITIALIZATION COMPLETE ===');
|
loyaltyAnalyticsLog.info('=== LOYALTY ANALYTICS PAGE INITIALIZATION COMPLETE ===');
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -166,6 +171,21 @@ function adminLoyaltyAnalytics() {
|
|||||||
await this.loadStats();
|
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) {
|
formatNumber(num) {
|
||||||
if (num === null || num === undefined) return '0';
|
if (num === null || num === undefined) return '0';
|
||||||
return new Intl.NumberFormat('en-US').format(num);
|
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,
|
enrolled: false,
|
||||||
enrolledCard: null,
|
enrolledCard: null,
|
||||||
error: null,
|
error: null,
|
||||||
|
showTerms: false,
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
console.log('Customer loyalty enroll initializing...');
|
console.log('Customer loyalty enroll initializing...');
|
||||||
@@ -73,8 +74,13 @@ function customerLoyaltyEnroll() {
|
|||||||
if (response) {
|
if (response) {
|
||||||
const cardNumber = response.card?.card_number || response.card_number;
|
const cardNumber = response.card?.card_number || response.card_number;
|
||||||
console.log('Enrollment successful:', cardNumber);
|
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 currentPath = window.location.pathname;
|
||||||
const successUrl = currentPath.replace(/\/join\/?$/, '/join/success') +
|
const successUrl = currentPath.replace(/\/join\/?$/, '/join/success') +
|
||||||
'?card=' + encodeURIComponent(cardNumber);
|
'?card=' + encodeURIComponent(cardNumber);
|
||||||
|
|||||||
@@ -50,6 +50,109 @@
|
|||||||
{% set show_merchants_metric = true %}
|
{% set show_merchants_metric = true %}
|
||||||
{% include "loyalty/shared/analytics-stats.html" %}
|
{% 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 -->
|
<!-- Quick Actions -->
|
||||||
<div class="px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
<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>
|
<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-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>
|
<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">
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||||
Save your card to your phone for easy access:
|
Save your card to your phone for easy access:
|
||||||
</p>
|
</p>
|
||||||
@@ -88,14 +89,15 @@ function customerLoyaltyEnrollSuccess() {
|
|||||||
return {
|
return {
|
||||||
...storefrontLayoutData(),
|
...storefrontLayoutData(),
|
||||||
walletUrls: { google_wallet_url: null, apple_wallet_url: null },
|
walletUrls: { google_wallet_url: null, apple_wallet_url: null },
|
||||||
async init() {
|
init() {
|
||||||
|
// Read wallet URLs saved during enrollment (no auth needed)
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/storefront/loyalty/card');
|
const stored = sessionStorage.getItem('loyalty_wallet_urls');
|
||||||
if (response && response.wallet_urls) {
|
if (stored) {
|
||||||
this.walletUrls = response.wallet_urls;
|
this.walletUrls = JSON.parse(stored);
|
||||||
|
sessionStorage.removeItem('loyalty_wallet_urls');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Customer may not be authenticated (public enrollment)
|
|
||||||
console.log('Could not load wallet URLs:', e.message);
|
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"
|
class="mt-1 w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||||||
style="color: var(--color-primary)">
|
style="color: var(--color-primary)">
|
||||||
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400">
|
<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>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-start">
|
<label class="flex items-start">
|
||||||
@@ -128,6 +134,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ class TestResolveCustomerId:
|
|||||||
assert customer.first_name == "Jane"
|
assert customer.first_name == "Jane"
|
||||||
assert customer.last_name == "Doe"
|
assert customer.last_name == "Doe"
|
||||||
assert customer.phone == "+352123456"
|
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.customer_number is not None
|
||||||
assert customer.is_active is True
|
assert customer.is_active is True
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user