All checks were successful
The web user-journey checklist (Tests 1–8) only covers human-using-loyalty flows from a browser. The cashier-facing Android tablet built in Phases A–F goes through a different surface and has its own failure modes that won't surface in any web test. Adding 6 dedicated Android tests so a tablet-in-hand verification has the same level of structure as the web side. - Test 9: Tablet pairing — QR scan + manual entry fallback, with the audit (paired-device row appears, last_seen_at populated) - Test 10: PIN screen — wrong/right PIN, offline-capable bcrypt verify, locked-PIN rejection - Test 11: Daily flows — search, scan, enroll, stamp, earn, redeem, with the acting_terminal_device_id audit column check at the end - Test 12: Offline queue + sync — airplane mode → queued → re-online → drain; redeem is hard-disabled offline per spec - Test 13: Auto-lock + manual lock — 2 min idle, immediate lock button, the known caveat that AlertDialog pointer events don't bubble - Test 14: Device revocation — revoke on web → 401 on tablet next call Updated the go-live readiness snapshot to reference these as Step 6b (gated on user obtaining a tablet, not on schedule). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
994 lines
50 KiB
Markdown
994 lines
50 KiB
Markdown
# Loyalty Module - User Journeys
|
||
|
||
## Personas
|
||
|
||
| # | Persona | Role / Auth | Description |
|
||
|---|---------|-------------|-------------|
|
||
| 1 | **Platform Admin** | `admin` role | Oversees all merchants' loyalty programs, views platform-wide stats, manages merchant settings |
|
||
| 2 | **Merchant Owner** | `store` role + owns merchant | Manages their merchant-wide loyalty program via the store interface. There is **no separate merchant owner UI** - loyalty programs are merchant-scoped but managed through any of the merchant's stores |
|
||
| 3 | **Store Staff / Team Member** | `store` role + store membership | Operates the POS terminal - scans cards, adds stamps/points, redeems rewards |
|
||
| 4 | **Customer (authenticated)** | Customer login | Views their loyalty card, balance, and transaction history |
|
||
| 5 | **Customer (anonymous)** | No auth | Browses program info, self-enrolls, downloads wallet passes |
|
||
|
||
!!! note "Merchant Owner vs Store Staff"
|
||
The loyalty module does **not** have a dedicated merchant owner interface. The merchant owner
|
||
accesses loyalty through the **store interface** (`/store/{store_code}/loyalty/...`). Since the
|
||
loyalty program is scoped at the merchant level (one program shared by all stores), the owner
|
||
can manage it from any of their stores. The difference is only in **permissions** - owners have
|
||
full access, team members have role-based access.
|
||
|
||
---
|
||
|
||
## Current Dev Database State
|
||
|
||
### Merchants & Stores
|
||
|
||
| Merchant | Owner | Stores |
|
||
|----------|-------|--------|
|
||
| WizaCorp Ltd. (id=1) | john.owner@wizacorp.com | ORION, WIZAGADGETS, WIZAHOME |
|
||
| Fashion Group S.A. (id=2) | jane.owner@fashiongroup.com | FASHIONHUB, FASHIONOUTLET |
|
||
| BookWorld Publishing (id=3) | bob.owner@bookworld.com | BOOKSTORE, BOOKDIGITAL |
|
||
|
||
### Users
|
||
|
||
| Email | Role | Type |
|
||
|-------|------|------|
|
||
| admin@orion.lu | admin | Platform admin |
|
||
| samir.boulahtit@gmail.com | admin | Platform admin |
|
||
| john.owner@wizacorp.com | store | Owner of WizaCorp (merchant 1) |
|
||
| jane.owner@fashiongroup.com | store | Owner of Fashion Group (merchant 2) |
|
||
| bob.owner@bookworld.com | store | Owner of BookWorld (merchant 3) |
|
||
| alice.manager@wizacorp.com | store | Team member (stores 1, 2) |
|
||
| charlie.staff@wizacorp.com | store | Team member (store 3) |
|
||
| diana.stylist@fashiongroup.com | store | Team member (stores 4, 5) |
|
||
| eric.sales@fashiongroup.com | store | Team member (store 5) |
|
||
| fiona.editor@bookworld.com | store | Team member (stores 6, 7) |
|
||
|
||
### Loyalty Data Status
|
||
|
||
| Table | Rows |
|
||
|-------|------|
|
||
| loyalty_programs | 0 |
|
||
| loyalty_cards | 0 |
|
||
| loyalty_transactions | 0 |
|
||
| merchant_loyalty_settings | 0 |
|
||
| staff_pins | 0 |
|
||
| merchant_subscriptions | 0 |
|
||
|
||
!!! warning "No loyalty programs exist yet"
|
||
All loyalty tables are empty. The first step in testing is to create a loyalty program
|
||
via the store interface. There are also **no subscriptions** set up, which may gate access
|
||
to the loyalty module depending on feature-gating configuration.
|
||
|
||
---
|
||
|
||
## Dev URLs (localhost:8000)
|
||
|
||
The dev server uses path-based platform routing: `http://localhost:8000/platforms/loyalty/...`
|
||
|
||
### 1. Platform Admin Pages
|
||
|
||
Login as: `admin@orion.lu` or `samir.boulahtit@gmail.com`
|
||
|
||
| Page | Dev URL |
|
||
|------|---------|
|
||
| Programs Dashboard | `http://localhost:8000/platforms/loyalty/admin/loyalty/programs` |
|
||
| Analytics | `http://localhost:8000/platforms/loyalty/admin/loyalty/analytics` |
|
||
| WizaCorp Detail | `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/1` |
|
||
| WizaCorp Settings | `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/1/settings` |
|
||
| Fashion Group Detail | `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/2` |
|
||
| Fashion Group Settings | `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/2/settings` |
|
||
| BookWorld Detail | `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/3` |
|
||
| BookWorld Settings | `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/3/settings` |
|
||
|
||
### 2. Merchant Owner / Store Pages
|
||
|
||
Login as the store owner, then navigate to any of their stores.
|
||
|
||
**WizaCorp (john.owner@wizacorp.com):**
|
||
|
||
| Page | Dev URL |
|
||
|------|---------|
|
||
| Terminal | `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/terminal` |
|
||
| Cards | `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/cards` |
|
||
| Settings | `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/settings` |
|
||
| Stats | `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/stats` |
|
||
| Enroll Customer | `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/enroll` |
|
||
|
||
**Fashion Group (jane.owner@fashiongroup.com):**
|
||
|
||
| Page | Dev URL |
|
||
|------|---------|
|
||
| Terminal | `http://localhost:8000/platforms/loyalty/store/FASHIONHUB/loyalty/terminal` |
|
||
| Cards | `http://localhost:8000/platforms/loyalty/store/FASHIONHUB/loyalty/cards` |
|
||
| Settings | `http://localhost:8000/platforms/loyalty/store/FASHIONHUB/loyalty/settings` |
|
||
| Stats | `http://localhost:8000/platforms/loyalty/store/FASHIONHUB/loyalty/stats` |
|
||
| Enroll Customer | `http://localhost:8000/platforms/loyalty/store/FASHIONHUB/loyalty/enroll` |
|
||
|
||
**BookWorld (bob.owner@bookworld.com):**
|
||
|
||
| Page | Dev URL |
|
||
|------|---------|
|
||
| Terminal | `http://localhost:8000/platforms/loyalty/store/BOOKSTORE/loyalty/terminal` |
|
||
| Cards | `http://localhost:8000/platforms/loyalty/store/BOOKSTORE/loyalty/cards` |
|
||
| Settings | `http://localhost:8000/platforms/loyalty/store/BOOKSTORE/loyalty/settings` |
|
||
| Stats | `http://localhost:8000/platforms/loyalty/store/BOOKSTORE/loyalty/stats` |
|
||
| Enroll Customer | `http://localhost:8000/platforms/loyalty/store/BOOKSTORE/loyalty/enroll` |
|
||
|
||
### 3. Customer Storefront Pages
|
||
|
||
Login as a customer (e.g., `customer1@orion.example.com`).
|
||
|
||
!!! note "Store code required in dev"
|
||
Storefront pages in dev require the store code in the URL path:
|
||
`/platforms/loyalty/storefront/{STORE_CODE}/...`. In production, the store
|
||
is resolved from the domain (custom domain, merchant domain, or subdomain).
|
||
|
||
| Page | Dev URL (FASHIONHUB example) |
|
||
|------|---------|
|
||
| Loyalty Dashboard | `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/account/loyalty` |
|
||
| Transaction History | `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/account/loyalty/history` |
|
||
|
||
### 4. Public Pages (No Auth)
|
||
|
||
| Page | Dev URL (FASHIONHUB example) |
|
||
|------|---------|
|
||
| Self-Enrollment | `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/loyalty/join` |
|
||
| Enrollment Success | `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/loyalty/join/success` |
|
||
|
||
### 5. API Endpoints
|
||
|
||
**Admin API** (prefix: `/platforms/loyalty/api/v1/admin/loyalty/`):
|
||
|
||
| Method | Dev URL |
|
||
|--------|---------|
|
||
| GET | `http://localhost:8000/platforms/loyalty/api/v1/admin/loyalty/programs` |
|
||
| GET | `http://localhost:8000/platforms/loyalty/api/v1/admin/loyalty/stats` |
|
||
|
||
**Store API** (prefix: `/platforms/loyalty/api/v1/store/loyalty/`):
|
||
|
||
| Method | Endpoint | Dev URL |
|
||
|--------|----------|---------|
|
||
| GET | program | `http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/program` |
|
||
| POST | program | `http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/program` |
|
||
| POST | stamp | `http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/stamp` |
|
||
| POST | points | `http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/points` |
|
||
| POST | enroll | `http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/cards/enroll` |
|
||
| POST | lookup | `http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/cards/lookup` |
|
||
|
||
**Storefront API** (prefix: `/platforms/loyalty/api/v1/storefront/`):
|
||
|
||
| Method | Endpoint | Dev URL |
|
||
|--------|----------|---------|
|
||
| GET | program | `http://localhost:8000/platforms/loyalty/api/v1/storefront/loyalty/program` |
|
||
| POST | enroll | `http://localhost:8000/platforms/loyalty/api/v1/storefront/loyalty/enroll` |
|
||
| GET | card | `http://localhost:8000/platforms/loyalty/api/v1/storefront/loyalty/card` |
|
||
| GET | transactions | `http://localhost:8000/platforms/loyalty/api/v1/storefront/loyalty/transactions` |
|
||
|
||
**Public API** (prefix: `/platforms/loyalty/api/v1/loyalty/`):
|
||
|
||
| Method | Endpoint | Dev URL |
|
||
|--------|----------|---------|
|
||
| GET | program | `http://localhost:8000/platforms/loyalty/api/v1/loyalty/programs/ORION` |
|
||
|
||
---
|
||
|
||
## Production URLs (rewardflow.lu)
|
||
|
||
In production, the platform uses **domain-based routing** instead of the `/platforms/loyalty/` path prefix.
|
||
Store context is detected via **custom domains** (registered in `store_domains` table)
|
||
or **subdomains** of `rewardflow.lu` (from `Store.subdomain`).
|
||
|
||
### URL Routing Summary
|
||
|
||
| Routing mode | Priority | Pattern | Example |
|
||
|-------------|----------|---------|---------|
|
||
| Platform domain | — | `rewardflow.lu/...` | Admin pages, public API |
|
||
| Store custom domain | 1 (highest) | `{custom_domain}/...` | Store with its own domain (overrides merchant domain) |
|
||
| Merchant domain | 2 | `{merchant_domain}/...` | All stores inherit merchant's domain |
|
||
| Store subdomain | 3 (fallback) | `{store_code}.rewardflow.lu/...` | Default when no custom/merchant domain |
|
||
|
||
!!! info "Domain Resolution Priority"
|
||
When a request arrives, the middleware resolves the store in this order:
|
||
|
||
1. **Store custom domain** (`store_domains` table) — highest priority, store-specific override
|
||
2. **Merchant domain** (`merchant_domains` table) — inherited by all merchant's stores
|
||
3. **Store subdomain** (`Store.subdomain` + platform domain) — fallback
|
||
|
||
### Case 1: Store with custom domain (e.g., `orion.shop`)
|
||
|
||
The store has a verified entry in the `store_domains` table. **All** store URLs
|
||
(storefront, store backend, store APIs) are served from the custom domain.
|
||
|
||
**Storefront (customer-facing):**
|
||
|
||
| Page | Production URL |
|
||
|------|----------------|
|
||
| Loyalty Dashboard | `https://orion.shop/account/loyalty` |
|
||
| Transaction History | `https://orion.shop/account/loyalty/history` |
|
||
| Self-Enrollment | `https://orion.shop/loyalty/join` |
|
||
| Enrollment Success | `https://orion.shop/loyalty/join/success` |
|
||
|
||
**Storefront API:**
|
||
|
||
| Method | Production URL |
|
||
|--------|----------------|
|
||
| GET card | `https://orion.shop/api/v1/storefront/loyalty/card` |
|
||
| GET transactions | `https://orion.shop/api/v1/storefront/loyalty/transactions` |
|
||
| POST enroll | `https://orion.shop/api/v1/storefront/loyalty/enroll` |
|
||
| GET program | `https://orion.shop/api/v1/storefront/loyalty/program` |
|
||
|
||
**Store backend (staff/owner):**
|
||
|
||
| Page | Production URL |
|
||
|------|----------------|
|
||
| Store Login | `https://orion.shop/store/ORION/login` |
|
||
| Terminal | `https://orion.shop/store/ORION/loyalty/terminal` |
|
||
| Cards | `https://orion.shop/store/ORION/loyalty/cards` |
|
||
| Card Detail | `https://orion.shop/store/ORION/loyalty/cards/{card_id}` |
|
||
| Settings | `https://orion.shop/store/ORION/loyalty/settings` |
|
||
| Stats | `https://orion.shop/store/ORION/loyalty/stats` |
|
||
| Enroll Customer | `https://orion.shop/store/ORION/loyalty/enroll` |
|
||
|
||
**Store API:**
|
||
|
||
| Method | Production URL |
|
||
|--------|----------------|
|
||
| GET program | `https://orion.shop/api/v1/store/loyalty/program` |
|
||
| POST program | `https://orion.shop/api/v1/store/loyalty/program` |
|
||
| POST stamp | `https://orion.shop/api/v1/store/loyalty/stamp` |
|
||
| POST points | `https://orion.shop/api/v1/store/loyalty/points` |
|
||
| POST enroll | `https://orion.shop/api/v1/store/loyalty/cards/enroll` |
|
||
| POST lookup | `https://orion.shop/api/v1/store/loyalty/cards/lookup` |
|
||
|
||
### Case 2: Store with merchant domain (e.g., `myloyaltyprogram.lu`)
|
||
|
||
The merchant has registered a domain in the `merchant_domains` table. Stores without
|
||
their own custom domain inherit the merchant domain. The middleware resolves the
|
||
merchant domain to the merchant's first active store by default, or to a specific
|
||
store when the URL includes `/store/{store_code}/...`.
|
||
|
||
**Storefront (customer-facing):**
|
||
|
||
| Page | Production URL |
|
||
|------|----------------|
|
||
| Loyalty Dashboard | `https://myloyaltyprogram.lu/account/loyalty` |
|
||
| Transaction History | `https://myloyaltyprogram.lu/account/loyalty/history` |
|
||
| Self-Enrollment | `https://myloyaltyprogram.lu/loyalty/join` |
|
||
| Enrollment Success | `https://myloyaltyprogram.lu/loyalty/join/success` |
|
||
|
||
**Storefront API:**
|
||
|
||
| Method | Production URL |
|
||
|--------|----------------|
|
||
| GET card | `https://myloyaltyprogram.lu/api/v1/storefront/loyalty/card` |
|
||
| GET transactions | `https://myloyaltyprogram.lu/api/v1/storefront/loyalty/transactions` |
|
||
| POST enroll | `https://myloyaltyprogram.lu/api/v1/storefront/loyalty/enroll` |
|
||
| GET program | `https://myloyaltyprogram.lu/api/v1/storefront/loyalty/program` |
|
||
|
||
**Store backend (staff/owner):**
|
||
|
||
| Page | Production URL |
|
||
|------|----------------|
|
||
| Store Login | `https://myloyaltyprogram.lu/store/WIZAGADGETS/login` |
|
||
| Terminal | `https://myloyaltyprogram.lu/store/WIZAGADGETS/loyalty/terminal` |
|
||
| Cards | `https://myloyaltyprogram.lu/store/WIZAGADGETS/loyalty/cards` |
|
||
| Settings | `https://myloyaltyprogram.lu/store/WIZAGADGETS/loyalty/settings` |
|
||
| Stats | `https://myloyaltyprogram.lu/store/WIZAGADGETS/loyalty/stats` |
|
||
|
||
**Store API:**
|
||
|
||
| Method | Production URL |
|
||
|--------|----------------|
|
||
| GET program | `https://myloyaltyprogram.lu/api/v1/store/loyalty/program` |
|
||
| POST stamp | `https://myloyaltyprogram.lu/api/v1/store/loyalty/stamp` |
|
||
| POST points | `https://myloyaltyprogram.lu/api/v1/store/loyalty/points` |
|
||
| POST enroll | `https://myloyaltyprogram.lu/api/v1/store/loyalty/cards/enroll` |
|
||
| POST lookup | `https://myloyaltyprogram.lu/api/v1/store/loyalty/cards/lookup` |
|
||
|
||
!!! note "Merchant domain resolves to first active store"
|
||
When a customer visits `myloyaltyprogram.lu` without a `/store/{code}/...` path,
|
||
the middleware resolves to the merchant's **first active store** (ordered by ID).
|
||
This is ideal for storefront pages like `/loyalty/join` where the customer doesn't
|
||
need to know which specific store they're interacting with.
|
||
|
||
### Case 3: Store without custom domain (uses platform subdomain)
|
||
|
||
The store has no entry in `store_domains` and the merchant has no registered domain.
|
||
**All** store URLs are served via a subdomain of the platform domain: `{store_code}.rewardflow.lu`.
|
||
|
||
**Storefront (customer-facing):**
|
||
|
||
| Page | Production URL |
|
||
|------|----------------|
|
||
| Loyalty Dashboard | `https://bookstore.rewardflow.lu/account/loyalty` |
|
||
| Transaction History | `https://bookstore.rewardflow.lu/account/loyalty/history` |
|
||
| Self-Enrollment | `https://bookstore.rewardflow.lu/loyalty/join` |
|
||
| Enrollment Success | `https://bookstore.rewardflow.lu/loyalty/join/success` |
|
||
|
||
**Storefront API:**
|
||
|
||
| Method | Production URL |
|
||
|--------|----------------|
|
||
| GET card | `https://bookstore.rewardflow.lu/api/v1/storefront/loyalty/card` |
|
||
| GET transactions | `https://bookstore.rewardflow.lu/api/v1/storefront/loyalty/transactions` |
|
||
| POST enroll | `https://bookstore.rewardflow.lu/api/v1/storefront/loyalty/enroll` |
|
||
| GET program | `https://bookstore.rewardflow.lu/api/v1/storefront/loyalty/program` |
|
||
|
||
**Store backend (staff/owner):**
|
||
|
||
| Page | Production URL |
|
||
|------|----------------|
|
||
| Store Login | `https://bookstore.rewardflow.lu/store/BOOKSTORE/login` |
|
||
| Terminal | `https://bookstore.rewardflow.lu/store/BOOKSTORE/loyalty/terminal` |
|
||
| Cards | `https://bookstore.rewardflow.lu/store/BOOKSTORE/loyalty/cards` |
|
||
| Settings | `https://bookstore.rewardflow.lu/store/BOOKSTORE/loyalty/settings` |
|
||
| Stats | `https://bookstore.rewardflow.lu/store/BOOKSTORE/loyalty/stats` |
|
||
|
||
**Store API:**
|
||
|
||
| Method | Production URL |
|
||
|--------|----------------|
|
||
| GET program | `https://bookstore.rewardflow.lu/api/v1/store/loyalty/program` |
|
||
| POST stamp | `https://bookstore.rewardflow.lu/api/v1/store/loyalty/stamp` |
|
||
| POST points | `https://bookstore.rewardflow.lu/api/v1/store/loyalty/points` |
|
||
| POST enroll | `https://bookstore.rewardflow.lu/api/v1/store/loyalty/cards/enroll` |
|
||
| POST lookup | `https://bookstore.rewardflow.lu/api/v1/store/loyalty/cards/lookup` |
|
||
|
||
### Platform Admin & Public API (always on platform domain)
|
||
|
||
| Page / Endpoint | Production URL |
|
||
|-----------------|----------------|
|
||
| Admin Programs | `https://rewardflow.lu/admin/loyalty/programs` |
|
||
| Admin Analytics | `https://rewardflow.lu/admin/loyalty/analytics` |
|
||
| Admin Merchant Detail | `https://rewardflow.lu/admin/loyalty/merchants/{id}` |
|
||
| Admin Merchant Settings | `https://rewardflow.lu/admin/loyalty/merchants/{id}/settings` |
|
||
| Admin API - Programs | `GET https://rewardflow.lu/api/v1/admin/loyalty/programs` |
|
||
| Admin API - Stats | `GET https://rewardflow.lu/api/v1/admin/loyalty/stats` |
|
||
| Public API - Program | `GET https://rewardflow.lu/api/v1/loyalty/programs/ORION` |
|
||
| Apple Wallet Pass | `GET https://rewardflow.lu/api/v1/loyalty/passes/apple/{serial}.pkpass` |
|
||
|
||
### Domain configuration per store (current DB state)
|
||
|
||
**Merchant domains** (`merchant_domains` table):
|
||
|
||
| Merchant | Merchant Domain | Status |
|
||
|----------|-----------------|--------|
|
||
| WizaCorp Ltd. | _(none yet)_ | — |
|
||
| Fashion Group S.A. | _(none yet)_ | — |
|
||
| BookWorld Publishing | _(none yet)_ | — |
|
||
|
||
**Store domains** (`store_domains` table) and effective resolution:
|
||
|
||
| Store | Merchant | Store Custom Domain | Effective Domain |
|
||
|-------|----------|---------------------|------------------|
|
||
| ORION | WizaCorp | `orion.shop` | `orion.shop` (store override) |
|
||
| FASHIONHUB | Fashion Group | `fashionhub.store` | `fashionhub.store` (store override) |
|
||
| WIZAGADGETS | WizaCorp | _(none)_ | `wizagadgets.rewardflow.lu` (subdomain fallback) |
|
||
| WIZAHOME | WizaCorp | _(none)_ | `wizahome.rewardflow.lu` (subdomain fallback) |
|
||
| FASHIONOUTLET | Fashion Group | _(none)_ | `fashionoutlet.rewardflow.lu` (subdomain fallback) |
|
||
| BOOKSTORE | BookWorld | _(none)_ | `bookstore.rewardflow.lu` (subdomain fallback) |
|
||
| BOOKDIGITAL | BookWorld | _(none)_ | `bookdigital.rewardflow.lu` (subdomain fallback) |
|
||
|
||
!!! example "After merchant domain registration"
|
||
If WizaCorp registers `myloyaltyprogram.lu` as their merchant domain, the table becomes:
|
||
|
||
| Store | Effective Domain | Reason |
|
||
|-------|------------------|--------|
|
||
| ORION | `orion.shop` | Store custom domain takes priority |
|
||
| WIZAGADGETS | `myloyaltyprogram.lu` | Inherits merchant domain |
|
||
| WIZAHOME | `myloyaltyprogram.lu` | Inherits merchant domain |
|
||
|
||
!!! info "`{store_domain}` in journey URLs"
|
||
In the journeys below, `{store_domain}` refers to the store's **effective domain**, resolved in priority order:
|
||
|
||
1. **Store custom domain**: `orion.shop` (from `store_domains` table) — highest priority
|
||
2. **Merchant domain**: `myloyaltyprogram.lu` (from `merchant_domains` table) — inherited default
|
||
3. **Subdomain fallback**: `orion.rewardflow.lu` (from `Store.subdomain` + platform domain)
|
||
|
||
---
|
||
|
||
## User Journeys
|
||
|
||
### Journey 0: Merchant Subscription & Domain Setup
|
||
|
||
**Persona:** Merchant Owner (e.g., john.owner@wizacorp.com) + Platform Admin
|
||
**Goal:** Subscribe to the loyalty platform, register a merchant domain, and optionally configure store domain overrides
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
A[Merchant owner logs in] --> B[Navigate to billing page]
|
||
B --> C[Choose subscription tier]
|
||
C --> D[Complete Stripe checkout]
|
||
D --> E[Subscription active]
|
||
E --> F{Register merchant domain?}
|
||
F -->|Yes| G[Admin registers merchant domain]
|
||
G --> H[Verify DNS ownership]
|
||
H --> I[Activate merchant domain]
|
||
I --> J{Store-specific override?}
|
||
J -->|Yes| K[Register store custom domain]
|
||
K --> L[Verify & activate store domain]
|
||
J -->|No| M[All stores inherit merchant domain]
|
||
F -->|No| N[Stores use subdomain fallback]
|
||
L --> O[Domain setup complete]
|
||
M --> O
|
||
N --> O
|
||
```
|
||
|
||
**Step 1: Subscribe to the platform**
|
||
|
||
1. Login as `john.owner@wizacorp.com` and navigate to billing:
|
||
- Dev: `http://localhost:8000/platforms/loyalty/store/ORION/billing`
|
||
- Prod (custom domain): `https://orion.shop/store/ORION/billing`
|
||
- Prod (subdomain): `https://orion.rewardflow.lu/store/ORION/billing`
|
||
2. View available subscription tiers:
|
||
- API Dev: `GET http://localhost:8000/platforms/loyalty/api/v1/store/billing/tiers`
|
||
- API Prod: `GET https://{store_domain}/api/v1/store/billing/tiers`
|
||
3. Select a tier and initiate Stripe checkout:
|
||
- API Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/billing/checkout`
|
||
- API Prod: `POST https://{store_domain}/api/v1/store/billing/checkout`
|
||
4. Complete payment on Stripe checkout page
|
||
5. Webhook `checkout.session.completed` activates the subscription
|
||
6. Verify subscription is active:
|
||
- API Dev: `GET http://localhost:8000/platforms/loyalty/api/v1/store/billing/subscription`
|
||
- API Prod: `GET https://{store_domain}/api/v1/store/billing/subscription`
|
||
|
||
**Step 2: Register merchant domain (admin action)**
|
||
|
||
!!! note "Admin-only operation"
|
||
Merchant domain registration is currently an admin operation. The platform admin
|
||
registers the domain on behalf of the merchant via the admin API.
|
||
|
||
1. Platform admin registers a merchant domain:
|
||
- API Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/admin/merchants/{merchant_id}/domains`
|
||
- API Prod: `POST https://rewardflow.lu/api/v1/admin/merchants/{merchant_id}/domains`
|
||
- Body: `{"domain": "myloyaltyprogram.lu", "is_primary": true}`
|
||
2. The API returns a `verification_token` for DNS verification
|
||
3. Get DNS verification instructions:
|
||
- API Dev: `GET http://localhost:8000/platforms/loyalty/api/v1/admin/merchants/domains/merchant/{domain_id}/verification-instructions`
|
||
- API Prod: `GET https://rewardflow.lu/api/v1/admin/merchants/domains/merchant/{domain_id}/verification-instructions`
|
||
4. Merchant adds a DNS TXT record: `_orion-verify.myloyaltyprogram.lu TXT {verification_token}`
|
||
5. Verify the domain:
|
||
- API Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/admin/merchants/domains/merchant/{domain_id}/verify`
|
||
- API Prod: `POST https://rewardflow.lu/api/v1/admin/merchants/domains/merchant/{domain_id}/verify`
|
||
6. Activate the domain:
|
||
- API Dev: `PUT http://localhost:8000/platforms/loyalty/api/v1/admin/merchants/domains/merchant/{domain_id}`
|
||
- API Prod: `PUT https://rewardflow.lu/api/v1/admin/merchants/domains/merchant/{domain_id}`
|
||
- Body: `{"is_active": true}`
|
||
7. All merchant stores now inherit `myloyaltyprogram.lu` as their effective domain
|
||
|
||
**Step 3: (Optional) Register store-specific domain override**
|
||
|
||
If a store needs its own domain (e.g., ORION is a major brand and wants `mysuperloyaltyprogram.lu`):
|
||
|
||
1. Platform admin registers a store domain:
|
||
- API Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/admin/stores/{store_id}/domains`
|
||
- API Prod: `POST https://rewardflow.lu/api/v1/admin/stores/{store_id}/domains`
|
||
- Body: `{"domain": "mysuperloyaltyprogram.lu", "is_primary": true}`
|
||
2. Follow the same DNS verification and activation flow as merchant domains
|
||
3. Once active, this store's effective domain becomes `mysuperloyaltyprogram.lu` (overrides merchant domain)
|
||
4. Other stores (WIZAGADGETS, WIZAHOME) continue to use `myloyaltyprogram.lu`
|
||
|
||
**Result after domain setup for WizaCorp:**
|
||
|
||
| Store | Effective Domain | Source |
|
||
|-------|------------------|--------|
|
||
| ORION | `mysuperloyaltyprogram.lu` | Store custom domain (override) |
|
||
| WIZAGADGETS | `myloyaltyprogram.lu` | Merchant domain (inherited) |
|
||
| WIZAHOME | `myloyaltyprogram.lu` | Merchant domain (inherited) |
|
||
|
||
**Expected blockers in current state:**
|
||
|
||
- No subscriptions exist yet - create one first via billing page or admin API
|
||
- No merchant domains registered - admin must register via API
|
||
- DNS verification requires actual DNS records (mock in tests)
|
||
|
||
---
|
||
|
||
### Journey 1: Merchant Owner - First-Time Setup
|
||
|
||
**Persona:** Merchant Owner (e.g., john.owner@wizacorp.com)
|
||
**Goal:** Set up a loyalty program for their merchant
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
A[Login as store owner] --> B[Navigate to store loyalty settings]
|
||
B --> C{Program exists?}
|
||
C -->|No| D[Create loyalty program]
|
||
D --> E[Choose type: stamps / points / hybrid]
|
||
E --> F[Configure program settings]
|
||
F --> G[Set branding - colors, logo]
|
||
G --> H[Configure anti-fraud settings]
|
||
H --> I[Create staff PINs]
|
||
I --> J[Program is live]
|
||
C -->|Yes| K[View/edit existing program]
|
||
```
|
||
|
||
**Steps:**
|
||
|
||
1. Login as `john.owner@wizacorp.com` at:
|
||
- Dev: `http://localhost:8000/platforms/loyalty/store/ORION/login`
|
||
- Prod (custom domain): `https://orion.shop/store/ORION/login`
|
||
- Prod (subdomain): `https://orion.rewardflow.lu/store/ORION/login`
|
||
2. Navigate to loyalty settings:
|
||
- Dev: `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/settings`
|
||
- Prod (custom domain): `https://orion.shop/store/ORION/loyalty/settings`
|
||
- Prod (subdomain): `https://orion.rewardflow.lu/store/ORION/loyalty/settings`
|
||
3. Create a new loyalty program:
|
||
- Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/program`
|
||
- Prod: `POST https://{store_domain}/api/v1/store/loyalty/program`
|
||
4. Choose loyalty type (stamps, points, or hybrid)
|
||
5. Configure program parameters (stamp target, points-per-euro, rewards)
|
||
6. Set branding (card color, logo, hero image)
|
||
7. Configure anti-fraud (cooldown, daily limits, PIN requirements)
|
||
8. Create staff PINs:
|
||
- Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/pins`
|
||
- Prod: `POST https://{store_domain}/api/v1/store/loyalty/pins`
|
||
9. Verify program is live - check from another store (same merchant):
|
||
- Dev: `http://localhost:8000/platforms/loyalty/store/WIZAGADGETS/loyalty/settings`
|
||
- Prod (subdomain): `https://wizagadgets.rewardflow.lu/store/WIZAGADGETS/loyalty/settings`
|
||
|
||
**Expected blockers in current state:**
|
||
|
||
- No loyalty programs exist - this is the first journey to test
|
||
|
||
!!! note "Subscription is not required for program creation"
|
||
The loyalty module currently has **no feature gating** — program creation works
|
||
without an active subscription. Journey 0 (subscription & domain setup) is
|
||
independent and can be done before or after program creation. However, in production
|
||
you would typically subscribe first to get a custom domain for your loyalty URLs.
|
||
|
||
---
|
||
|
||
### Journey 2: Store Staff - Daily Operations (Stamps)
|
||
|
||
**Persona:** Store Staff (e.g., alice.manager@wizacorp.com)
|
||
**Goal:** Process customer loyalty stamp transactions
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
A[Open terminal] --> B[Customer presents card/QR]
|
||
B --> C[Scan/lookup card]
|
||
C --> D[Enter staff PIN]
|
||
D --> E[Add stamp]
|
||
E --> F{Target reached?}
|
||
F -->|Yes| G[Prompt: Redeem reward?]
|
||
G -->|Yes| H[Redeem stamps for reward]
|
||
G -->|No| I[Save for later]
|
||
F -->|No| J[Done - show updated count]
|
||
H --> J
|
||
I --> J
|
||
```
|
||
|
||
**Steps:**
|
||
|
||
1. Login as `alice.manager@wizacorp.com` and open the terminal:
|
||
- Dev: `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/terminal`
|
||
- Prod: `https://{store_domain}/store/ORION/loyalty/terminal`
|
||
2. Scan customer QR code or enter card number:
|
||
- Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/cards/lookup`
|
||
- Prod: `POST https://{store_domain}/api/v1/store/loyalty/cards/lookup`
|
||
3. Enter staff PIN for verification
|
||
4. Add stamp:
|
||
- Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/stamp`
|
||
- Prod: `POST https://{store_domain}/api/v1/store/loyalty/stamp`
|
||
5. If target reached, redeem reward:
|
||
- Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/stamp/redeem`
|
||
- Prod: `POST https://{store_domain}/api/v1/store/loyalty/stamp/redeem`
|
||
6. View updated card:
|
||
- Dev: `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/cards/{card_id}`
|
||
- Prod: `https://{store_domain}/store/ORION/loyalty/cards/{card_id}`
|
||
7. Browse all cards:
|
||
- Dev: `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/cards`
|
||
- Prod: `https://{store_domain}/store/ORION/loyalty/cards`
|
||
|
||
**Anti-fraud scenarios to test:**
|
||
|
||
- Cooldown rejection (stamp within 15 min)
|
||
- Daily limit hit (max 5 stamps/day)
|
||
- PIN lockout (5 failed attempts)
|
||
|
||
---
|
||
|
||
### Journey 3: Store Staff - Daily Operations (Points)
|
||
|
||
**Persona:** Store Staff (e.g., alice.manager@wizacorp.com)
|
||
**Goal:** Process customer loyalty points from purchase
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
A[Open terminal] --> B[Customer presents card]
|
||
B --> C[Scan/lookup card]
|
||
C --> D[Enter purchase amount]
|
||
D --> E[Enter staff PIN]
|
||
E --> F[Points calculated & added]
|
||
F --> G{Enough for reward?}
|
||
G -->|Yes| H[Offer redemption]
|
||
G -->|No| I[Done - show balance]
|
||
H --> I
|
||
```
|
||
|
||
**Steps:**
|
||
|
||
1. Open the terminal:
|
||
- Dev: `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/terminal`
|
||
- Prod: `https://{store_domain}/store/ORION/loyalty/terminal`
|
||
2. Lookup card:
|
||
- Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/cards/lookup`
|
||
- Prod: `POST https://{store_domain}/api/v1/store/loyalty/cards/lookup`
|
||
3. Enter purchase amount (e.g., 25.00 EUR)
|
||
4. Earn points (auto-calculated at 10 pts/EUR = 250 points):
|
||
- Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/points`
|
||
- Prod: `POST https://{store_domain}/api/v1/store/loyalty/points`
|
||
5. If enough balance, redeem points for reward:
|
||
- Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/points/redeem`
|
||
- Prod: `POST https://{store_domain}/api/v1/store/loyalty/points/redeem`
|
||
6. Check store-level stats:
|
||
- Dev: `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/stats`
|
||
- Prod: `https://{store_domain}/store/ORION/loyalty/stats`
|
||
|
||
---
|
||
|
||
### Journey 4: Customer Self-Enrollment
|
||
|
||
**Persona:** Anonymous Customer
|
||
**Goal:** Join a merchant's loyalty program
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
A[See QR code at store counter] --> B[Scan QR / visit enrollment page]
|
||
B --> C[Fill in details - email, name]
|
||
C --> D[Submit enrollment]
|
||
D --> E[Receive card number]
|
||
E --> F[Optional: Add to Apple/Google Wallet]
|
||
F --> G[Start collecting stamps/points]
|
||
```
|
||
|
||
**Steps:**
|
||
|
||
1. Visit the public enrollment page:
|
||
- Dev: `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/loyalty/join`
|
||
- Prod (custom domain): `https://fashionhub.store/loyalty/join`
|
||
- Prod (subdomain): `https://bookstore.rewardflow.lu/loyalty/join`
|
||
2. Fill in enrollment form (email, name)
|
||
3. Submit enrollment:
|
||
- Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/storefront/loyalty/enroll`
|
||
- Prod (custom domain): `POST https://fashionhub.store/api/v1/storefront/loyalty/enroll`
|
||
- Prod (subdomain): `POST https://bookstore.rewardflow.lu/api/v1/storefront/loyalty/enroll`
|
||
4. Redirected to success page:
|
||
- Dev: `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/loyalty/join/success?card=XXXX-XXXX-XXXX`
|
||
- Prod (custom domain): `https://fashionhub.store/loyalty/join/success?card=XXXX-XXXX-XXXX`
|
||
- Prod (subdomain): `https://bookstore.rewardflow.lu/loyalty/join/success?card=XXXX-XXXX-XXXX`
|
||
5. Optionally download Apple Wallet pass:
|
||
- Dev: `GET http://localhost:8000/platforms/loyalty/api/v1/loyalty/passes/apple/{serial_number}.pkpass`
|
||
- Prod: `GET https://rewardflow.lu/api/v1/loyalty/passes/apple/{serial_number}.pkpass`
|
||
|
||
---
|
||
|
||
### Journey 5: Customer - View Loyalty Status
|
||
|
||
**Persona:** Authenticated Customer (e.g., `customer1@orion.example.com`)
|
||
**Goal:** Check loyalty balance and history
|
||
|
||
**Steps:**
|
||
|
||
1. Login as customer at the storefront
|
||
2. View loyalty dashboard (card balance, available rewards):
|
||
- Dev: `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/account/loyalty`
|
||
- Prod (custom domain): `https://fashionhub.store/account/loyalty`
|
||
- Prod (subdomain): `https://bookstore.rewardflow.lu/account/loyalty`
|
||
- API Dev: `GET http://localhost:8000/platforms/loyalty/api/v1/storefront/loyalty/card`
|
||
- API Prod: `GET https://fashionhub.store/api/v1/storefront/loyalty/card`
|
||
3. View full transaction history:
|
||
- Dev: `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/account/loyalty/history`
|
||
- Prod (custom domain): `https://fashionhub.store/account/loyalty/history`
|
||
- Prod (subdomain): `https://bookstore.rewardflow.lu/account/loyalty/history`
|
||
- API Dev: `GET http://localhost:8000/platforms/loyalty/api/v1/storefront/loyalty/transactions`
|
||
- API Prod: `GET https://fashionhub.store/api/v1/storefront/loyalty/transactions`
|
||
|
||
---
|
||
|
||
### Journey 6: Platform Admin - Oversight
|
||
|
||
**Persona:** Platform Admin (`admin@orion.lu` or `samir.boulahtit@gmail.com`)
|
||
**Goal:** Monitor all loyalty programs across merchants
|
||
|
||
**Steps:**
|
||
|
||
1. Login as admin
|
||
2. View all programs:
|
||
- Dev: `http://localhost:8000/platforms/loyalty/admin/loyalty/programs`
|
||
- Prod: `https://rewardflow.lu/admin/loyalty/programs`
|
||
3. View platform-wide analytics:
|
||
- Dev: `http://localhost:8000/platforms/loyalty/admin/loyalty/analytics`
|
||
- Prod: `https://rewardflow.lu/admin/loyalty/analytics`
|
||
4. Drill into WizaCorp's program:
|
||
- Dev: `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/1`
|
||
- Prod: `https://rewardflow.lu/admin/loyalty/merchants/1`
|
||
5. Manage WizaCorp's merchant-level settings:
|
||
- Dev: `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/1/settings`
|
||
- Prod: `https://rewardflow.lu/admin/loyalty/merchants/1/settings`
|
||
- API Dev: `PATCH http://localhost:8000/platforms/loyalty/api/v1/admin/loyalty/merchants/1/settings`
|
||
- API Prod: `PATCH https://rewardflow.lu/api/v1/admin/loyalty/merchants/1/settings`
|
||
6. Adjust settings: PIN policy, self-enrollment toggle, void permissions
|
||
7. Check other merchants:
|
||
- Dev: `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/2`
|
||
- Prod: `https://rewardflow.lu/admin/loyalty/merchants/2`
|
||
|
||
---
|
||
|
||
### Journey 7: Void / Return Flow
|
||
|
||
**Persona:** Store Staff (e.g., alice.manager@wizacorp.com)
|
||
**Goal:** Reverse a loyalty transaction (customer return)
|
||
|
||
**Steps:**
|
||
|
||
1. Open terminal and lookup card:
|
||
- Dev: `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/terminal`
|
||
- Prod: `https://{store_domain}/store/ORION/loyalty/terminal`
|
||
- Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/cards/lookup`
|
||
- Prod: `POST https://{store_domain}/api/v1/store/loyalty/cards/lookup`
|
||
2. View the card's transaction history to find the transaction to void:
|
||
- Dev: `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/cards/{card_id}`
|
||
- Prod: `https://{store_domain}/store/ORION/loyalty/cards/{card_id}`
|
||
- API Dev: `GET http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/cards/{card_id}/transactions`
|
||
- API Prod: `GET https://{store_domain}/api/v1/store/loyalty/cards/{card_id}/transactions`
|
||
3. Void a stamp transaction:
|
||
- Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/stamp/void`
|
||
- Prod: `POST https://{store_domain}/api/v1/store/loyalty/stamp/void`
|
||
4. Or void a points transaction:
|
||
- Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/points/void`
|
||
- Prod: `POST https://{store_domain}/api/v1/store/loyalty/points/void`
|
||
5. Verify: original and void transactions are linked in the audit log
|
||
|
||
---
|
||
|
||
### Journey 8: Cross-Store Redemption
|
||
|
||
**Persona:** Customer + Store Staff at two different stores
|
||
**Goal:** Customer earns at Store A, redeems at Store B (same merchant)
|
||
|
||
**Precondition:** Cross-location redemption must be enabled in merchant settings:
|
||
|
||
- Dev: `http://localhost:8000/platforms/loyalty/admin/loyalty/merchants/1/settings`
|
||
- Prod: `https://rewardflow.lu/admin/loyalty/merchants/1/settings`
|
||
|
||
**Steps:**
|
||
|
||
1. Staff at ORION adds stamps to customer's card:
|
||
- Dev: `http://localhost:8000/platforms/loyalty/store/ORION/loyalty/terminal`
|
||
- Prod: `https://{store_domain}/store/ORION/loyalty/terminal`
|
||
- Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/stamp`
|
||
- Prod: `POST https://{store_domain}/api/v1/store/loyalty/stamp`
|
||
2. Customer visits WIZAGADGETS
|
||
3. Staff at WIZAGADGETS looks up the same card:
|
||
- Dev: `http://localhost:8000/platforms/loyalty/store/WIZAGADGETS/loyalty/terminal`
|
||
- Prod: `https://{store_domain}/store/WIZAGADGETS/loyalty/terminal`
|
||
- Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/cards/lookup`
|
||
- Prod: `POST https://{store_domain}/api/v1/store/loyalty/cards/lookup`
|
||
4. Card is found (same merchant) with accumulated stamps
|
||
5. Staff at WIZAGADGETS redeems the reward:
|
||
- Dev: `POST http://localhost:8000/platforms/loyalty/api/v1/store/loyalty/stamp/redeem`
|
||
- Prod: `POST https://{store_domain}/api/v1/store/loyalty/stamp/redeem`
|
||
6. Verify transaction history shows both stores:
|
||
- Dev: `http://localhost:8000/platforms/loyalty/store/WIZAGADGETS/loyalty/cards/{card_id}`
|
||
- Prod: `https://{store_domain}/store/WIZAGADGETS/loyalty/cards/{card_id}`
|
||
|
||
---
|
||
|
||
## Recommended Test Order
|
||
|
||
1. **Journey 1** - Create a program first (nothing else works without this)
|
||
2. **Journey 0** - Subscribe and set up domains (independent, but needed for custom domain URLs)
|
||
3. **Journey 4** - Enroll a test customer
|
||
4. **Journey 2 or 3** - Process stamps/points
|
||
5. **Journey 5** - Verify customer can see their data
|
||
6. **Journey 7** - Test void/return
|
||
7. **Journey 8** - Test cross-store (enroll via ORION, redeem via WIZAGADGETS)
|
||
8. **Journey 6** - Admin overview (verify data appears correctly)
|
||
|
||
!!! tip "Journey 0 and Journey 1 are independent"
|
||
There is no feature gating on loyalty program creation — you can test them in
|
||
either order. Journey 0 is listed second because domain setup is about URL
|
||
presentation, not a functional prerequisite for the loyalty module.
|
||
|
||
---
|
||
|
||
## Pre-Launch E2E Test Checklist (Fashion Group)
|
||
|
||
Manual end-to-end checklist using Fashion Group (merchant 2: FASHIONHUB + FASHIONOUTLET).
|
||
Covers all customer-facing flows including the cross-store enrollment and redemption features
|
||
added in the Phase 1 production launch hardening.
|
||
|
||
### Pre-requisite: Program Setup (Journey 1)
|
||
|
||
If Fashion Group doesn't have a loyalty program yet:
|
||
|
||
1. Login as `jane.owner@fashiongroup.com` at `http://localhost:8000/platforms/loyalty/store/FASHIONHUB/login`
|
||
2. Navigate to: `http://localhost:8000/platforms/loyalty/store/FASHIONHUB/loyalty/settings`
|
||
3. Create program (hybrid or points), set welcome bonus, enable self-enrollment
|
||
4. Verify Cross-Location Redemption is **enabled** in merchant settings
|
||
|
||
### Test 1: Customer Self-Enrollment (Journey 4)
|
||
|
||
| Step | Action | Expected Result |
|
||
|------|--------|-----------------|
|
||
| 1.1 | Visit `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/loyalty/join` | Enrollment form loads, no console errors |
|
||
| 1.2 | Fill in: fresh email, name, **birthday** → Submit | Redirected to success page with card number |
|
||
| 1.3 | Check DB: `SELECT birth_date FROM customers WHERE email = '...'` | `birth_date` is set (not NULL) |
|
||
| 1.4 | Enroll **without** birthday (different email) | Success, `birth_date` is NULL (no crash) |
|
||
|
||
### Test 2: Cross-Store Re-Enrollment (Cross-Location Enabled)
|
||
|
||
| Step | Action | Expected Result |
|
||
|------|--------|-----------------|
|
||
| 2.1 | Visit `http://localhost:8000/platforms/loyalty/storefront/FASHIONOUTLET/loyalty/join` | Enrollment form loads |
|
||
| 2.2 | Submit with the **same email** from Test 1 | Success page shows **"You're already a member!"** |
|
||
| 2.3 | Check: store list shown | Blue box: "Your card works at all our locations:" with Fashion Hub + Fashion Outlet listed |
|
||
| 2.4 | Check: same card number as Test 1 | Card number matches (no duplicate created) |
|
||
| 2.5 | Check DB: `SELECT COUNT(*) FROM loyalty_cards WHERE customer_id = ...` | Exactly 1 card |
|
||
| 2.6 | Re-enroll at FASHIONHUB (same store as original) | Same behavior: "already a member" + locations |
|
||
| 2.7 | Refresh the success page | Message persists, no flicker, no untranslated i18n keys |
|
||
|
||
### Test 3: Staff Operations — Stamps/Points (Journeys 2 & 3)
|
||
|
||
| Step | Action | Expected Result |
|
||
|------|--------|-----------------|
|
||
| 3.1 | Login as `jane.owner@fashiongroup.com` at FASHIONHUB | Login succeeds |
|
||
| 3.2 | Open terminal: `.../store/FASHIONHUB/loyalty/terminal` | Terminal loads |
|
||
| 3.3 | Look up card by card number | Card found, balance displayed |
|
||
| 3.4 | Look up card by customer email | Card found (same result) |
|
||
| 3.5 | Add stamp (or earn points with purchase amount) | Count/balance updates |
|
||
| 3.6 | Add stamp again immediately (within cooldown) | Rejected: cooldown active |
|
||
|
||
### Test 4: Cross-Store Redemption (Journey 8)
|
||
|
||
| Step | Action | Expected Result |
|
||
|------|--------|-----------------|
|
||
| 4.1 | Staff at FASHIONHUB adds stamps/points to the card | Balance updated |
|
||
| 4.2 | Login as staff at FASHIONOUTLET (e.g., `diana.stylist@fashiongroup.com` or `jane.owner`) | Login succeeds |
|
||
| 4.3 | Open terminal: `.../store/FASHIONOUTLET/loyalty/terminal` | Terminal loads |
|
||
| 4.4 | Look up card **by email** | Card found (cross-store email search) |
|
||
| 4.5 | Look up card **by card number** | Card found |
|
||
| 4.6 | Redeem reward (if enough stamps/points) | Redemption succeeds |
|
||
| 4.7 | View card detail | Transaction history shows entries from both FASHIONHUB and FASHIONOUTLET |
|
||
|
||
### Test 5: Customer Views Status (Journey 5)
|
||
|
||
| Step | Action | Expected Result |
|
||
|------|--------|-----------------|
|
||
| 5.1 | Login as the customer at storefront | Customer dashboard loads |
|
||
| 5.2 | Dashboard: `.../storefront/FASHIONHUB/account/loyalty` | Shows balance, available rewards |
|
||
| 5.3 | History: `.../storefront/FASHIONHUB/account/loyalty/history` | Shows transactions from both stores |
|
||
|
||
### Test 6: Void/Return (Journey 7)
|
||
|
||
| Step | Action | Expected Result |
|
||
|------|--------|-----------------|
|
||
| 6.1 | Staff at FASHIONHUB opens terminal, looks up card | Card found |
|
||
| 6.2 | Void a stamp or points transaction | Balance adjusted |
|
||
| 6.3 | Check transaction history | Void transaction appears, linked to original |
|
||
|
||
### Test 7: Admin Oversight (Journey 6)
|
||
|
||
| Step | Action | Expected Result |
|
||
|------|--------|-----------------|
|
||
| 7.1 | Login as `samir.boulahtit@gmail.com` (admin) | Admin dashboard loads |
|
||
| 7.2 | Programs: `.../admin/loyalty/programs` | Fashion Group program visible |
|
||
| 7.3 | Fashion Group detail: `.../admin/loyalty/merchants/2` | Cards, transactions, stats appear correctly |
|
||
| 7.4 | Fashion Group settings: `.../admin/loyalty/merchants/2/settings` | Cross-location toggle visible and correct |
|
||
|
||
### Test 8: Cross-Location Disabled Behavior
|
||
|
||
| Step | Action | Expected Result |
|
||
|------|--------|-----------------|
|
||
| 8.1 | Admin disables Cross-Location Redemption for Fashion Group | Setting saved |
|
||
| 8.2 | Enroll a **new email** at FASHIONHUB | New card created for FASHIONHUB |
|
||
| 8.3 | Enroll **same email** at FASHIONOUTLET | **New card created** for FASHIONOUTLET (separate card) |
|
||
| 8.4 | Enroll **same email** at FASHIONHUB again | "Already a member" — shows "Your card is registered at Fashion Hub" (single store, no list) |
|
||
| 8.5 | Staff at FASHIONOUTLET searches by email | Only finds the FASHIONOUTLET card (no cross-store search) |
|
||
| 8.6 | Re-enable Cross-Location Redemption when done | Restore default state |
|
||
|
||
### Key Things to Watch
|
||
|
||
- [ ] Birthday persisted after enrollment (check DB)
|
||
- [ ] No i18n flicker or console warnings on success page
|
||
- [ ] Cross-store email search works in the terminal (cross-location enabled)
|
||
- [ ] "Already a member" message shows correct locations/store based on cross-location setting
|
||
- [ ] No duplicate cards created under same merchant (when cross-location enabled)
|
||
|
||
## Android Terminal Tests (clients/terminal-android)
|
||
|
||
Same merchant (Fashion Group / FASHIONHUB), with the Android tablet app
|
||
built from `clients/terminal-android/`. Build the APK with
|
||
`./gradlew assembleDebug` and install via `./gradlew installDebug` to a
|
||
tablet or emulator on the same network as prod (`https://rewardflow.lu`)
|
||
or your dev server (`http://10.0.2.2:8000` for the emulator).
|
||
|
||
Use a separate test customer (e.g. `terminal-test@example.com`) — none
|
||
of the verifications below should touch the customers from web Tests
|
||
1–8 to keep audit trails clean.
|
||
|
||
### Test 9: Tablet Pairing
|
||
|
||
| Step | Action | Expected Result |
|
||
|------|--------|-----------------|
|
||
| 9.1 | Web admin: visit `/merchants/loyalty/devices` as merchant owner | List page loads, "Pair Tablet" button visible |
|
||
| 9.2 | Click "Pair Tablet" → label "Counter A", store FASHIONHUB → Confirm | Modal shows QR + raw JSON, device row appears with `status=active` |
|
||
| 9.3 | Tablet: launch app on first boot — landscape setup screen with camera preview | Camera permission prompt; QR scanner active |
|
||
| 9.4 | Point camera at the web modal's QR | Decoded → AuthInterceptor swaps host + bearer → `/program` 200 → navigates to PIN screen |
|
||
| 9.5 | Web admin: list refreshes | Same device row shows `last_seen_at` populated within ~5s |
|
||
| 9.6 | Tablet: kill + relaunch app | Stays on PIN screen (paired flag persists in DataStore) |
|
||
| 9.7 | **Manual entry path:** wipe app data (`adb shell pm clear lu.rewardflow.terminal`), relaunch | SetupScreen renders the right-hand form |
|
||
| 9.8 | Enter api_url / store_id / store_code / auth_token from the modal's "Show raw payload" → Connect | Same success outcome as QR path |
|
||
|
||
### Test 10: PIN Screen (Offline-Capable Verify)
|
||
|
||
Pre-requisite: at least one staff PIN created at `/merchants/loyalty/pins`
|
||
for FASHIONHUB. Use a memorable 4-digit PIN.
|
||
|
||
| Step | Action | Expected Result |
|
||
|------|--------|-----------------|
|
||
| 10.1 | Tablet: arrives on PIN screen after Test 9 | Left pane lists staff names with avatar circles; right pane shows PIN keypad |
|
||
| 10.2 | Type the **wrong PIN** | Shake/error: "Wrong PIN", digits clear, keypad still responsive |
|
||
| 10.3 | Type the **right PIN** | Brief spinner → navigates to Terminal screen, header shows staff name |
|
||
| 10.4 | **Offline-capable verify:** turn off wifi, lock screen (top-right Lock button), type PIN again | Verifies offline against cached bcrypt hashes; bcrypt-verifies locally |
|
||
| 10.5 | Web admin: lock the PIN by triggering 5 wrong attempts via web terminal | `is_locked=true` in DB |
|
||
| 10.6 | Tablet: refresh PIN cache (next launch, or after ~5 min) → try the locked PIN | Rejected: locked PINs never match |
|
||
|
||
### Test 11: Daily Flows from the Tablet
|
||
|
||
| Step | Action | Expected Result |
|
||
|------|--------|-----------------|
|
||
| 11.1 | Terminal screen: type a customer email in the search field → Search | Customer card panel renders with name, balance, available rewards |
|
||
| 11.2 | Clear (top-right ×) → tap "Scan QR Code" → scan a card QR (use the card's `qr_code_data` field) | Card looked up, same panel renders |
|
||
| 11.3 | Clear → tap "Enroll Customer" → fill name + email + birthday → Enroll | Success → customer pane renders with new card pre-selected |
|
||
| 11.4 | With customer selected, tap "Add Stamp" → pick a category pill → Confirm | Stamp count increments by 1, recent transactions list shows it |
|
||
| 11.5 | "Earn Points" → enter `12.50` (or `12,50`) → pick a category → Confirm | Points balance increases by 125 (assuming 10 pts/€), feed updated |
|
||
| 11.6 | Stamp until 10/10 → "Redeem Stamps" → Confirm | Stamp count resets to 0, reward description shown in transactions |
|
||
| 11.7 | "Redeem Reward" with enough points → pick a reward → Confirm | Points decremented, reward feed entry shown |
|
||
| 11.8 | Backend audit: `SELECT acting_terminal_device_id FROM loyalty_transactions ORDER BY id DESC LIMIT 5` | Latest 5 rows have `acting_terminal_device_id` set to the paired device id |
|
||
|
||
### Test 12: Offline Queue + Sync
|
||
|
||
| Step | Action | Expected Result |
|
||
|------|--------|-----------------|
|
||
| 12.1 | Tablet: airplane mode ON (or emulator: extended controls → cellular off) | Top-bar pill flips to "Offline" |
|
||
| 12.2 | "Add Stamp" with categories → Confirm | Snackbar: "Queued — will sync when back online"; top bar shows "1 pending sync" |
|
||
| 12.3 | "Earn Points" with amount + categories → Confirm | Same: queued, pending count → 2 |
|
||
| 12.4 | Tap "Redeem Stamps" (any redeem) | Button is **disabled** when offline (per plan; redeem requires authoritative balance) |
|
||
| 12.5 | Re-enable wifi | NetworkMonitor flips to Online, sync worker fires under network constraint |
|
||
| 12.6 | Within ~5–10s, pending count drops to 0 | Recent transactions feed auto-refreshes with the queued operations now applied |
|
||
| 12.7 | Backend audit: queued rows in `loyalty_transactions` carry `acting_terminal_device_id` | All synced operations attributed to this device |
|
||
| 12.8 | DB: `SELECT COUNT(*) FROM pending_transactions WHERE status='pending'` (Room db on device — verify via adb if needed) | 0 — synced rows deleted, queue empty |
|
||
|
||
### Test 13: Auto-Lock + Manual Lock
|
||
|
||
| Step | Action | Expected Result |
|
||
|------|--------|-----------------|
|
||
| 13.1 | After verifying PIN, leave the Terminal screen untouched for ~2 minutes | IdleTracker fires `onIdle` → navigates back to PIN screen |
|
||
| 13.2 | Verify PIN → Terminal → tap the top-right Lock button | Immediate return to PIN screen |
|
||
| 13.3 | While an action dialog (e.g. "Earn Points") is open, wait 2 min | Tracker still locks behind the dialog — pointer events on Compose dialogs don't bubble to the tracker. Intended behavior. |
|
||
|
||
### Test 14: Device Revocation
|
||
|
||
| Step | Action | Expected Result |
|
||
|------|--------|-----------------|
|
||
| 14.1 | Web admin: `/merchants/loyalty/devices` → click the orange Revoke icon on the paired tablet's row → Confirm | Row shows "Revoked" status, `revoked_at` set |
|
||
| 14.2 | Tablet: try any action (e.g. lookup a card) | API returns 401 `TERMINAL_DEVICE_REVOKED`; cashier sees the error inline |
|
||
| 14.3 | Tablet: kill + relaunch | Still rejects — token is no longer valid; merchant must re-pair |
|
||
| 14.4 | Re-pair from the web → tablet reset + new QR scan | Restored to working state |
|
||
|
||
### Things to Watch on the Tablet
|
||
|
||
- [ ] Camera permission prompt is in-place (no jarring redirect to system settings)
|
||
- [ ] QR decode is single-shot — scanner doesn't fire twice on the same code
|
||
- [ ] PIN keypad stays responsive even during bcrypt verify (it's brief but visible)
|
||
- [ ] Action dialogs auto-close on success; failure messages quote the server's actual error (e.g. "Daily stamp limit reached"), not a generic "HTTP 400"
|
||
- [ ] Offline pill in top bar matches actual connectivity state (toggle wifi and watch it flip)
|
||
- [ ] "Pending sync N" badge increments on queue, drops on drain
|
||
- [ ] After successful action, balance card + recent feed update without manual refresh
|
||
- [ ] Immersive mode keeps system bars hidden; swipe down briefly reveals them, then auto-hide
|
||
- [ ] Rate limiting: rapid-fire stamp calls eventually return 429
|