feat(tenancy): add merchant-level domain with store override
Merchants can now register domains (e.g., myloyaltyprogram.lu) that all their stores inherit. Individual stores can override with their own custom domain. Resolution priority: StoreDomain > MerchantDomain > subdomain. - Add MerchantDomain model, schema, service, and admin API endpoints - Add merchant domain fallback in platform and store context middleware - Add Merchant.primary_domain and Store.effective_domain properties - Add Alembic migration for merchant_domains table - Update loyalty user journey docs with subscription & domain setup flow - Add unit tests (50 passing) and integration tests (15 passing) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -181,11 +181,19 @@ or **subdomains** of `loyalty.lu` (from `Store.subdomain`).
|
||||
|
||||
### URL Routing Summary
|
||||
|
||||
| Routing mode | Pattern | Example |
|
||||
|-------------|---------|---------|
|
||||
| Platform domain | `loyalty.lu/...` | Admin pages, public API |
|
||||
| Custom domain | `{custom_domain}/...` | All store pages (store has custom domain) |
|
||||
| Store subdomain | `{store_code}.loyalty.lu/...` | All store pages (no custom domain) |
|
||||
| Routing mode | Priority | Pattern | Example |
|
||||
|-------------|----------|---------|---------|
|
||||
| Platform domain | — | `loyalty.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}.loyalty.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., `wizamart.shop`)
|
||||
|
||||
@@ -233,10 +241,61 @@ The store has a verified entry in the `store_domains` table. **All** store URLs
|
||||
| POST enroll | `https://wizamart.shop/api/store/loyalty/cards/enroll` |
|
||||
| POST lookup | `https://wizamart.shop/api/store/loyalty/cards/lookup` |
|
||||
|
||||
### Case 2: Store without custom domain (uses platform subdomain)
|
||||
### Case 2: Store with merchant domain (e.g., `myloyaltyprogram.lu`)
|
||||
|
||||
The store has no entry in `store_domains`. **All** store URLs are served via a
|
||||
subdomain of the platform domain: `{store_code}.loyalty.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/storefront/loyalty/card` |
|
||||
| GET transactions | `https://myloyaltyprogram.lu/api/storefront/loyalty/transactions` |
|
||||
| POST enroll | `https://myloyaltyprogram.lu/api/storefront/loyalty/enroll` |
|
||||
| GET program | `https://myloyaltyprogram.lu/api/storefront/loyalty/program` |
|
||||
|
||||
**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/store/loyalty/program` |
|
||||
| POST stamp | `https://myloyaltyprogram.lu/api/store/loyalty/stamp` |
|
||||
| POST points | `https://myloyaltyprogram.lu/api/store/loyalty/points` |
|
||||
| POST enroll | `https://myloyaltyprogram.lu/api/store/loyalty/cards/enroll` |
|
||||
| POST lookup | `https://myloyaltyprogram.lu/api/store/loyalty/cards/lookup` |
|
||||
|
||||
!!! 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}.loyalty.lu`.
|
||||
|
||||
**Storefront (customer-facing):**
|
||||
|
||||
@@ -291,26 +350,141 @@ subdomain of the platform domain: `{store_code}.loyalty.lu`.
|
||||
|
||||
### Domain configuration per store (current DB state)
|
||||
|
||||
| Store | Custom Domain | Production URL |
|
||||
|-------|---------------|----------------|
|
||||
| WIZAMART | `wizamart.shop` | `https://wizamart.shop/...` |
|
||||
| FASHIONHUB | `fashionhub.store` | `https://fashionhub.store/...` |
|
||||
| WIZAGADGETS | _(none)_ | `https://wizagadgets.loyalty.lu/...` |
|
||||
| WIZAHOME | _(none)_ | `https://wizahome.loyalty.lu/...` |
|
||||
| FASHIONOUTLET | _(none)_ | `https://fashionoutlet.loyalty.lu/...` |
|
||||
| BOOKSTORE | _(none)_ | `https://bookstore.loyalty.lu/...` |
|
||||
| BOOKDIGITAL | _(none)_ | `https://bookdigital.loyalty.lu/...` |
|
||||
**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 |
|
||||
|-------|----------|---------------------|------------------|
|
||||
| WIZAMART | WizaCorp | `wizamart.shop` | `wizamart.shop` (store override) |
|
||||
| FASHIONHUB | Fashion Group | `fashionhub.store` | `fashionhub.store` (store override) |
|
||||
| WIZAGADGETS | WizaCorp | _(none)_ | `wizagadgets.loyalty.lu` (subdomain fallback) |
|
||||
| WIZAHOME | WizaCorp | _(none)_ | `wizahome.loyalty.lu` (subdomain fallback) |
|
||||
| FASHIONOUTLET | Fashion Group | _(none)_ | `fashionoutlet.loyalty.lu` (subdomain fallback) |
|
||||
| BOOKSTORE | BookWorld | _(none)_ | `bookstore.loyalty.lu` (subdomain fallback) |
|
||||
| BOOKDIGITAL | BookWorld | _(none)_ | `bookdigital.loyalty.lu` (subdomain fallback) |
|
||||
|
||||
!!! example "After merchant domain registration"
|
||||
If WizaCorp registers `myloyaltyprogram.lu` as their merchant domain, the table becomes:
|
||||
|
||||
| Store | Effective Domain | Reason |
|
||||
|-------|------------------|--------|
|
||||
| WIZAMART | `wizamart.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 resolved domain:
|
||||
In the journeys below, `{store_domain}` refers to the store's **effective domain**, resolved in priority order:
|
||||
|
||||
- **Custom domain**: `wizamart.shop` (from `store_domains` table)
|
||||
- **Subdomain fallback**: `wizamart.loyalty.lu` (from `Store.subdomain` + platform domain)
|
||||
1. **Store custom domain**: `wizamart.shop` (from `store_domains` table) — highest priority
|
||||
2. **Merchant domain**: `myloyaltyprogram.lu` (from `merchant_domains` table) — inherited default
|
||||
3. **Subdomain fallback**: `wizamart.loyalty.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:9999/platforms/loyalty/store/WIZAMART/billing`
|
||||
- Prod (custom domain): `https://wizamart.shop/store/WIZAMART/billing`
|
||||
- Prod (subdomain): `https://wizamart.loyalty.lu/store/WIZAMART/billing`
|
||||
2. View available subscription tiers:
|
||||
- API Dev: `GET http://localhost:9999/platforms/loyalty/api/v1/store/billing/tiers`
|
||||
- API Prod: `GET https://{store_domain}/api/v1/store/billing/tiers`
|
||||
3. Select a tier and initiate Stripe checkout:
|
||||
- API Dev: `POST http://localhost:9999/platforms/loyalty/api/v1/store/billing/checkout`
|
||||
- API Prod: `POST https://{store_domain}/api/v1/store/billing/checkout`
|
||||
4. Complete payment on Stripe checkout page
|
||||
5. Webhook `checkout.session.completed` activates the subscription
|
||||
6. Verify subscription is active:
|
||||
- API Dev: `GET http://localhost:9999/platforms/loyalty/api/v1/store/billing/subscription`
|
||||
- API 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:9999/platforms/loyalty/api/v1/admin/merchants/{merchant_id}/domains`
|
||||
- API Prod: `POST https://loyalty.lu/api/v1/admin/merchants/{merchant_id}/domains`
|
||||
- Body: `{"domain": "myloyaltyprogram.lu", "is_primary": true}`
|
||||
2. The API returns a `verification_token` for DNS verification
|
||||
3. Get DNS verification instructions:
|
||||
- API Dev: `GET http://localhost:9999/platforms/loyalty/api/v1/admin/merchants/domains/merchant/{domain_id}/verification-instructions`
|
||||
- API Prod: `GET https://loyalty.lu/api/v1/admin/merchants/domains/merchant/{domain_id}/verification-instructions`
|
||||
4. Merchant adds a DNS TXT record: `_wizamart-verify.myloyaltyprogram.lu TXT {verification_token}`
|
||||
5. Verify the domain:
|
||||
- API Dev: `POST http://localhost:9999/platforms/loyalty/api/v1/admin/merchants/domains/merchant/{domain_id}/verify`
|
||||
- API Prod: `POST https://loyalty.lu/api/v1/admin/merchants/domains/merchant/{domain_id}/verify`
|
||||
6. Activate the domain:
|
||||
- API Dev: `PUT http://localhost:9999/platforms/loyalty/api/v1/admin/merchants/domains/merchant/{domain_id}`
|
||||
- API Prod: `PUT https://loyalty.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., WIZAMART is a major brand and wants `mysuperloyaltyprogram.lu`):
|
||||
|
||||
1. Platform admin registers a store domain:
|
||||
- API Dev: `POST http://localhost:9999/platforms/loyalty/api/v1/admin/stores/{store_id}/domains`
|
||||
- API Prod: `POST https://loyalty.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 |
|
||||
|-------|------------------|--------|
|
||||
| WIZAMART | `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)
|
||||
@@ -356,9 +530,14 @@ flowchart TD
|
||||
|
||||
**Expected blockers in current state:**
|
||||
|
||||
- No subscriptions exist - feature gating may prevent program creation
|
||||
- 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)
|
||||
@@ -601,9 +780,15 @@ flowchart TD
|
||||
## Recommended Test Order
|
||||
|
||||
1. **Journey 1** - Create a program first (nothing else works without this)
|
||||
2. **Journey 4** - Enroll a test customer
|
||||
3. **Journey 2 or 3** - Process stamps/points
|
||||
4. **Journey 5** - Verify customer can see their data
|
||||
5. **Journey 7** - Test void/return
|
||||
6. **Journey 8** - Test cross-store (enroll via WIZAMART, redeem via WIZAGADGETS)
|
||||
7. **Journey 6** - Admin overview (verify data appears correctly)
|
||||
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 WIZAMART, 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.
|
||||
|
||||
Reference in New Issue
Block a user