Files
orion/app/modules/loyalty/docs/user-journeys.md
Samir Boulahtit f804ff8442
Some checks failed
CI / ruff (push) Successful in 16s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
fix(loyalty): cross-store enrollment, card scoping, i18n flicker
Fix duplicate card creation when the same email enrolls at different
stores under the same merchant, and implement cross-location-aware
enrollment behavior.

- Cross-location enabled (default): one card per customer per merchant.
  Re-enrolling at another store returns the existing card with a
  "works at all our locations" message + store list.
- Cross-location disabled: one card per customer per store. Enrolling
  at a different store creates a separate card for that store.

Changes:
- Migration loyalty_004: replace (merchant_id, customer_id) unique
  index with (enrolled_at_store_id, customer_id). Per-merchant
  uniqueness enforced at application layer when cross-location enabled.
- card_service.resolve_customer_id: cross-store email lookup via
  merchant_id param to find existing cardholders at other stores.
- card_service.enroll_customer: branch duplicate check on
  allow_cross_location_redemption setting.
- card_service.search_card_for_store: cross-store email search when
  cross-location enabled so staff at store2 can find cards from store1.
- card_service.get_card_by_customer_and_store: new service method.
- storefront enrollment: catch LoyaltyCardAlreadyExistsException,
  return existing card with already_enrolled flag, locations, and
  cross-location context. Server-rendered i18n via Jinja2 tojson.
- enroll-success.html: conditional cross-store/single-store messaging,
  server-rendered translations and context, i18n_modules block added.
- dashboard.html, history.html: replace $t() with server-side _() to
  fix i18n flicker across all storefront templates.
- Fix device-mobile icon → phone icon.
- 4 new i18n keys in 4 locales (en, fr, de, lb).
- Docs: updated data-model, business-logic, production-launch-plan,
  user-journeys with cross-location behavior and E2E test checklist.
- 12 new unit tests + 3 new integration tests (334 total pass).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:28:19 +02:00

43 KiB

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

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

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

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

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

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}

  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)
  • Rate limiting: rapid-fire stamp calls eventually return 429