Files
orion/app/modules/loyalty/docs/user-journeys.md
Samir Boulahtit a3fb7029bd
All checks were successful
CI / ruff (push) Successful in 14s
CI / pytest (push) Successful in 2h25m39s
CI / validate (push) Successful in 31s
CI / dependency-scanning (push) Successful in 32s
CI / docs (push) Successful in 50s
CI / deploy (push) Successful in 1m11s
docs(loyalty): add Android terminal E2E tests to user-journeys checklist
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>
2026-05-10 22:05:00 +02:00

994 lines
50 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
18 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 ~510s, 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