docs: migrate module documentation to single source of truth

Move 39 documentation files from top-level docs/ into each module's
docs/ folder, accessible via symlinks from docs/modules/. Create
data-model.md files for 10 modules with full schema documentation.
Replace originals with redirect stubs. Remove empty guide stubs.

Modules migrated: tenancy, billing, loyalty, marketplace, orders,
messaging, cms, catalog, inventory, hosting, prospecting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 23:38:37 +01:00
parent 2287f4597d
commit f141cc4e6a
140 changed files with 19921 additions and 17723 deletions

View File

@@ -1,414 +1 @@
# CMS Implementation Guide
## Quick Start
This guide shows you how to implement the Content Management System for static pages.
## What Was Implemented
**Database Model**: `models/database/content_page.py`
**Service Layer**: `app/services/content_page_service.py`
**Admin API**: `app/api/v1/admin/content_pages.py`
**Store API**: `app/api/v1/store/content_pages.py`
**Storefront API**: `app/api/v1/storefront/content_pages.py`
**Documentation**: Full CMS documentation in `docs/features/content-management-system.md`
## Next Steps to Activate
### 1. Create Database Migration
```bash
# Create Alembic migration
alembic revision --autogenerate -m "Add content_pages table"
# Review the generated migration in alembic/versions/
# Run migration
alembic upgrade head
```
### 2. Add Relationship to Store Model
Edit `models/database/store.py` and add this relationship:
```python
# Add this import
from sqlalchemy.orm import relationship
# Add this relationship to Store class
content_pages = relationship("ContentPage", back_populates="store", cascade="all, delete-orphan")
```
### 3. Register API Routers
Edit the appropriate router files to include the new endpoints:
**Admin Router** (`app/api/v1/admin/__init__.py`):
```python
from app.api.v1.admin import content_pages
api_router.include_router(
content_pages.router,
prefix="/content-pages",
tags=["admin-content-pages"]
)
```
**Store Router** (`app/api/v1/store/__init__.py`):
```python
from app.api.v1.store import content_pages
api_router.include_router(
content_pages.router,
prefix="/{store_code}/content-pages",
tags=["store-content-pages"]
)
```
**Storefront Router** (`app/api/v1/storefront/__init__.py` or create if doesn't exist):
```python
from app.api.v1.storefront import content_pages
api_router.include_router(
content_pages.router,
prefix="/content-pages",
tags=["storefront-content-pages"]
)
```
### 4. Update Storefront Routes to Use CMS
Edit `app/routes/storefront_pages.py` to add a generic content page handler:
```python
from app.services.content_page_service import content_page_service
@router.get("/{slug}", response_class=HTMLResponse, include_in_schema=False)
async def generic_content_page(
slug: str,
request: Request,
db: Session = Depends(get_db)
):
"""
Generic content page handler.
Handles: /about, /faq, /contact, /shipping, /returns, /privacy, /terms, etc.
"""
store = getattr(request.state, 'store', None)
store_id = store.id if store else None
page = content_page_service.get_page_for_store(
db,
slug=slug,
store_id=store_id,
include_unpublished=False
)
if not page:
raise HTTPException(status_code=404, detail=f"Page not found: {slug}")
return templates.TemplateResponse(
"storefront/content-page.html",
get_storefront_context(request, page=page)
)
```
### 5. Create Generic Content Page Template
Create `app/templates/storefront/content-page.html`:
```jinja2
{# app/templates/storefront/content-page.html #}
{% extends "storefront/base.html" %}
{% block title %}{{ page.title }}{% endblock %}
{% block meta_description %}
{{ page.meta_description or page.title }}
{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{# Breadcrumbs #}
<nav class="mb-6 text-sm">
<a href="{{ base_url }}" class="text-primary hover:underline">Home</a>
<span class="mx-2 text-gray-400">/</span>
<span class="text-gray-600 dark:text-gray-300">{{ page.title }}</span>
</nav>
{# Page Title #}
<h1 class="text-4xl font-bold text-gray-900 dark:text-gray-100 mb-8">
{{ page.title }}
</h1>
{# Content #}
<div class="prose prose-lg dark:prose-invert max-w-none">
{{ page.content | safe }}
</div>
{# Last updated #}
{% if page.updated_at %}
<div class="mt-12 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-500 dark:text-gray-400">
Last updated: {{ page.updated_at.strftime('%B %d, %Y') }}
</div>
{% endif %}
</div>
{% endblock %}
```
### 6. Update Footer to Load Navigation Dynamically
Edit `app/templates/storefront/base.html` to load navigation from database.
First, update the context helper to include footer pages:
```python
# app/routes/storefront_pages.py
def get_storefront_context(request: Request, **extra_context) -> dict:
# ... existing code ...
# Load footer navigation pages
db = next(get_db())
try:
footer_pages = content_page_service.list_pages_for_store(
db,
store_id=store.id if store else None,
include_unpublished=False,
footer_only=True
)
finally:
db.close()
context = {
"request": request,
"store": store,
"theme": theme,
"clean_path": clean_path,
"access_method": access_method,
"base_url": base_url,
"footer_pages": footer_pages, # Add this
**extra_context
}
return context
```
Then update the footer template:
```jinja2
{# app/templates/storefront/base.html - Footer section #}
<div>
<h4 class="font-semibold mb-4">Quick Links</h4>
<ul class="space-y-2">
{% for page in footer_pages %}
<li>
<a href="{{ base_url }}{{ page.slug }}"
class="text-gray-600 hover:text-primary dark:text-gray-400">
{{ page.title }}
</a>
</li>
{% endfor %}
</ul>
</div>
```
### 7. Create Default Platform Pages (Script)
Create `scripts/seed/create_default_content_pages.py`:
```python
#!/usr/bin/env python3
"""Create default platform content pages."""
from sqlalchemy.orm import Session
from app.core.database import SessionLocal
from app.services.content_page_service import content_page_service
def create_defaults():
db: Session = SessionLocal()
try:
# About Us
content_page_service.create_page(
db,
slug="about",
title="About Us",
content="""
<h2>Welcome to Our Marketplace</h2>
<p>We connect quality stores with customers worldwide.</p>
<p>Our mission is to provide a seamless shopping experience...</p>
""",
is_published=True,
show_in_footer=True,
display_order=1
)
# Shipping Information
content_page_service.create_page(
db,
slug="shipping",
title="Shipping Information",
content="""
<h2>Shipping Policy</h2>
<p>We offer fast and reliable shipping...</p>
""",
is_published=True,
show_in_footer=True,
display_order=2
)
# Returns
content_page_service.create_page(
db,
slug="returns",
title="Returns & Refunds",
content="""
<h2>Return Policy</h2>
<p>30-day return policy on all items...</p>
""",
is_published=True,
show_in_footer=True,
display_order=3
)
# Privacy Policy
content_page_service.create_page(
db,
slug="privacy",
title="Privacy Policy",
content="""
<h2>Privacy Policy</h2>
<p>Your privacy is important to us...</p>
""",
is_published=True,
show_in_footer=True,
display_order=4
)
# Terms of Service
content_page_service.create_page(
db,
slug="terms",
title="Terms of Service",
content="""
<h2>Terms of Service</h2>
<p>By using our platform, you agree to...</p>
""",
is_published=True,
show_in_footer=True,
display_order=5
)
# Contact
content_page_service.create_page(
db,
slug="contact",
title="Contact Us",
content="""
<h2>Get in Touch</h2>
<p>Have questions? We'd love to hear from you!</p>
<p>Email: support@example.com</p>
""",
is_published=True,
show_in_footer=True,
display_order=6
)
# FAQ
content_page_service.create_page(
db,
slug="faq",
title="Frequently Asked Questions",
content="""
<h2>FAQ</h2>
<h3>How do I place an order?</h3>
<p>Simply browse our products...</p>
""",
is_published=True,
show_in_footer=True,
display_order=7
)
print("✅ Created default content pages successfully")
except Exception as e:
print(f"❌ Error: {e}")
db.rollback()
finally:
db.close()
if __name__ == "__main__":
create_defaults()
```
Run it:
```bash
python scripts/seed/create_default_content_pages.py
```
## Testing
### 1. Test Platform Defaults
```bash
# Create platform default
curl -X POST http://localhost:8000/api/v1/admin/content-pages/platform \
-H "Authorization: Bearer <admin_token>" \
-H "Content-Type: application/json" \
-d '{
"slug": "about",
"title": "About Our Marketplace",
"content": "<h1>About</h1><p>Platform default content</p>",
"is_published": true,
"show_in_footer": true
}'
# View in storefront
curl http://localhost:8000/store/orion/about
```
### 2. Test Store Override
```bash
# Create store override
curl -X POST http://localhost:8000/api/v1/store/orion/content-pages/ \
-H "Authorization: Bearer <store_token>" \
-H "Content-Type: application/json" \
-d '{
"slug": "about",
"title": "About Orion",
"content": "<h1>About Orion</h1><p>Custom store content</p>",
"is_published": true
}'
# View in storefront (should show store content)
curl http://localhost:8000/store/orion/about
```
### 3. Test Fallback
```bash
# Delete store override
curl -X DELETE http://localhost:8000/api/v1/store/orion/content-pages/{id} \
-H "Authorization: Bearer <store_token>"
# View in storefront (should fall back to platform default)
curl http://localhost:8000/store/orion/about
```
## Summary
You now have a complete CMS system that allows:
1. **Platform admins** to create default content for all stores
2. **Stores** to override specific pages with custom content
3. **Automatic fallback** to platform defaults when store hasn't customized
4. **Dynamic navigation** loading from database
5. **SEO optimization** with meta tags
6. **Draft/Published workflow** for content management
All pages are accessible via their slug: `/about`, `/faq`, `/contact`, etc. with proper store context and routing support!
This document has moved to the CMS module docs: [Implementation](../modules/cms/implementation.md)

View File

@@ -1,604 +1 @@
# Content Management System (CMS)
## Overview
The Content Management System allows platform administrators and stores to manage static content pages like About, FAQ, Contact, Shipping, Returns, Privacy Policy, Terms of Service, etc.
**Key Features:**
- ✅ Platform-level default content
- ✅ Store-specific overrides
- ✅ Fallback system (store → platform default)
- ✅ Rich text content (HTML/Markdown)
- ✅ SEO metadata
- ✅ Published/Draft status
- ✅ Navigation management (footer/header)
- ✅ Display order control
## Architecture
### Two-Tier Content System
```
┌─────────────────────────────────────────────────────────────┐
│ CONTENT LOOKUP FLOW │
└─────────────────────────────────────────────────────────────┘
Request: /about
1. Check for store-specific override
SELECT * FROM content_pages
WHERE store_id = 123 AND slug = 'about' AND is_published = true
Found? ✅ Return store content
❌ Continue to step 2
2. Check for platform default
SELECT * FROM content_pages
WHERE store_id IS NULL AND slug = 'about' AND is_published = true
Found? ✅ Return platform content
❌ Return 404 or default template
```
### Database Schema
```sql
CREATE TABLE content_pages (
id SERIAL PRIMARY KEY,
-- Store association (NULL = platform default)
store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE,
-- Page identification
slug VARCHAR(100) NOT NULL, -- about, faq, contact, shipping, returns
title VARCHAR(200) NOT NULL,
-- Content
content TEXT NOT NULL, -- HTML or Markdown
content_format VARCHAR(20) DEFAULT 'html', -- html, markdown
-- SEO
meta_description VARCHAR(300),
meta_keywords VARCHAR(300),
-- Publishing
is_published BOOLEAN DEFAULT FALSE NOT NULL,
published_at TIMESTAMP WITH TIME ZONE,
-- Navigation placement
display_order INTEGER DEFAULT 0,
show_in_footer BOOLEAN DEFAULT TRUE, -- Quick Links column
show_in_header BOOLEAN DEFAULT FALSE, -- Top navigation
show_in_legal BOOLEAN DEFAULT FALSE, -- Bottom bar with copyright
-- Timestamps
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
-- Author tracking
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
-- Constraints
CONSTRAINT uq_store_slug UNIQUE (store_id, slug)
);
CREATE INDEX idx_store_published ON content_pages (store_id, is_published);
CREATE INDEX idx_slug_published ON content_pages (slug, is_published);
```
## Usage
### Platform Administrator Workflow
**1. Create Platform Default Pages**
Platform admins create default content that all stores inherit:
```bash
POST /api/v1/admin/content-pages/platform
{
"slug": "about",
"title": "About Us",
"content": "<h1>About Us</h1><p>We are a marketplace...</p>",
"content_format": "html",
"meta_description": "Learn more about our marketplace",
"is_published": true,
"show_in_header": true,
"show_in_footer": true,
"show_in_legal": false,
"display_order": 1
}
```
**Common Platform Defaults:**
- `about` - About Us
- `contact` - Contact Us
- `faq` - Frequently Asked Questions
- `shipping` - Shipping Information
- `returns` - Return Policy
- `privacy` - Privacy Policy
- `terms` - Terms of Service
- `help` - Help Center
**2. View All Content Pages**
```bash
GET /api/v1/admin/content-pages/
GET /api/v1/admin/content-pages/?store_id=123 # Filter by store
GET /api/v1/admin/content-pages/platform # Only platform defaults
```
**3. Update Platform Default**
```bash
PUT /api/v1/admin/content-pages/1
{
"title": "Updated About Us",
"content": "<h1>About Our Platform</h1>...",
"is_published": true
}
```
### Store Workflow
**1. View Available Pages**
Stores see their overrides + platform defaults:
```bash
GET /api/v1/store/{code}/content-pages/
```
Response:
```json
[
{
"id": 15,
"slug": "about",
"title": "About Orion", // Store override
"is_store_override": true,
"is_platform_page": false
},
{
"id": 2,
"slug": "shipping",
"title": "Shipping Information", // Platform default
"is_store_override": false,
"is_platform_page": true
}
]
```
**2. Create Store Override**
Store creates custom "About" page:
```bash
POST /api/v1/store/{code}/content-pages/
{
"slug": "about",
"title": "About Orion",
"content": "<h1>About Orion</h1><p>We specialize in...</p>",
"is_published": true
}
```
This overrides the platform default for this store only.
**3. View Only Store Overrides**
```bash
GET /api/v1/store/{code}/content-pages/overrides
```
Shows what the store has customized (excludes platform defaults).
**4. Delete Override (Revert to Platform Default)**
```bash
DELETE /api/v1/store/{code}/content-pages/15
```
After deletion, platform default will be shown again.
### Storefront (Public)
**1. Get Page Content**
```bash
GET /api/v1/storefront/content-pages/about
```
Automatically uses store context from middleware:
- Returns store override if exists
- Falls back to platform default
- Returns 404 if neither exists
**2. Get Navigation Links**
```bash
# Get all navigation pages
GET /api/v1/storefront/content-pages/navigation
# Filter by placement
GET /api/v1/storefront/content-pages/navigation?header_only=true
GET /api/v1/storefront/content-pages/navigation?footer_only=true
GET /api/v1/storefront/content-pages/navigation?legal_only=true
```
Returns published pages filtered by navigation placement.
## File Structure
```
app/
├── models/database/
│ └── content_page.py ← Database model
├── services/
│ └── content_page_service.py ← Business logic
├── api/v1/
│ ├── admin/
│ │ └── content_pages.py ← Admin API endpoints
│ ├── store/
│ │ └── content_pages.py ← Store API endpoints
│ └── storefront/
│ └── content_pages.py ← Public API endpoints
└── templates/storefront/
├── about.html ← Content page template
├── faq.html
├── contact.html
└── ...
```
## Template Integration
### Generic Content Page Template
Create a reusable template for all content pages:
```jinja2
{# app/templates/storefront/content-page.html #}
{% extends "storefront/base.html" %}
{% block title %}{{ page.title }}{% endblock %}
{% block meta_description %}
{{ page.meta_description or page.title }}
{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{# Breadcrumbs #}
<nav class="mb-6">
<a href="{{ base_url }}" class="text-primary hover:underline">Home</a>
<span class="mx-2">/</span>
<span class="text-gray-600">{{ page.title }}</span>
</nav>
{# Page Title #}
<h1 class="text-4xl font-bold text-gray-900 dark:text-gray-100 mb-8">
{{ page.title }}
</h1>
{# Content #}
<div class="prose dark:prose-invert max-w-none">
{% if page.content_format == 'markdown' %}
{{ page.content | markdown }}
{% else %}
{{ page.content | safe }}
{% endif %}
</div>
{# Last updated #}
{% if page.updated_at %}
<div class="mt-12 pt-6 border-t text-sm text-gray-500">
Last updated: {{ page.updated_at }}
</div>
{% endif %}
</div>
{% endblock %}
```
### Route Handler
```python
# app/routes/storefront_pages.py
from app.services.content_page_service import content_page_service
@router.get("/{slug}", response_class=HTMLResponse)
async def content_page(
slug: str,
request: Request,
db: Session = Depends(get_db)
):
"""
Generic content page handler.
Loads content from database with store override support.
"""
store = getattr(request.state, 'store', None)
store_id = store.id if store else None
page = content_page_service.get_page_for_store(
db,
slug=slug,
store_id=store_id,
include_unpublished=False
)
if not page:
raise HTTPException(status_code=404, detail=f"Page not found: {slug}")
return templates.TemplateResponse(
"storefront/content-page.html",
get_storefront_context(request, page=page)
)
```
### Dynamic Footer Navigation
Update footer to load links from database:
```jinja2
{# app/templates/storefront/base.html #}
<footer>
<div class="grid grid-cols-3">
<div>
<h4>Quick Links</h4>
<ul>
{% for page in footer_pages %}
<li>
<a href="{{ base_url }}{{ page.slug }}">
{{ page.title }}
</a>
</li>
{% endfor %}
</ul>
</div>
</div>
</footer>
```
## Best Practices
### 1. Content Formatting
**HTML Content:**
```html
<h1>About Us</h1>
<p>We are a <strong>leading marketplace</strong> for...</p>
<ul>
<li>Quality products</li>
<li>Fast shipping</li>
<li>Great support</li>
</ul>
```
**Markdown Content:**
```markdown
# About Us
We are a **leading marketplace** for...
- Quality products
- Fast shipping
- Great support
```
### 2. SEO Optimization
Always provide meta descriptions:
```json
{
"meta_description": "Learn about our marketplace, mission, and values. We connect stores with customers worldwide.",
"meta_keywords": "about us, marketplace, e-commerce, mission"
}
```
### 3. Draft → Published Workflow
1. Create page with `is_published: false`
2. Preview using `include_unpublished=true` parameter
3. Review and edit
4. Publish with `is_published: true`
### 4. Navigation Management
The CMS supports three navigation placement categories:
```
┌─────────────────────────────────────────────────────────────────┐
│ HEADER (show_in_header=true) │
│ [Logo] About Us Contact [Login] [Sign Up] │
├─────────────────────────────────────────────────────────────────┤
│ │
│ PAGE CONTENT │
│ │
├─────────────────────────────────────────────────────────────────┤
│ FOOTER (show_in_footer=true) │
│ ┌──────────────┬──────────────┬────────────┬──────────────┐ │
│ │ Quick Links │ Platform │ Contact │ Social │ │
│ │ • About │ • Admin │ • Email │ • Twitter │ │
│ │ • FAQ │ • Store │ • Phone │ • LinkedIn │ │
│ │ • Contact │ │ │ │ │
│ │ • Shipping │ │ │ │ │
│ └──────────────┴──────────────┴────────────┴──────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ LEGAL BAR (show_in_legal=true) │
│ © 2025 Orion Privacy Policy │ Terms │
└─────────────────────────────────────────────────────────────────┘
```
**Navigation Categories:**
| Category | Field | Location | Typical Pages |
|----------|-------|----------|---------------|
| Header | `show_in_header` | Top navigation bar | About, Contact |
| Footer | `show_in_footer` | Quick Links column | FAQ, Shipping, Returns |
| Legal | `show_in_legal` | Bottom bar with © | Privacy, Terms |
**Use `display_order` to control link ordering within each category:**
```python
# Platform defaults with navigation placement
"about": display_order=1, show_in_header=True, show_in_footer=True
"contact": display_order=2, show_in_header=True, show_in_footer=True
"faq": display_order=3, show_in_footer=True
"shipping": display_order=4, show_in_footer=True
"returns": display_order=5, show_in_footer=True
"privacy": display_order=6, show_in_legal=True
"terms": display_order=7, show_in_legal=True
```
### 5. Content Reversion
To revert store override back to platform default:
```bash
# Store deletes their custom page
DELETE /api/v1/store/{code}/content-pages/15
# Platform default will now be shown automatically
```
## Common Page Slugs
Standard slugs to implement:
| Slug | Title | Header | Footer | Legal | Order |
|------|-------|--------|--------|-------|-------|
| `about` | About Us | ✅ | ✅ | ❌ | 1 |
| `contact` | Contact Us | ✅ | ✅ | ❌ | 2 |
| `faq` | FAQ | ❌ | ✅ | ❌ | 3 |
| `shipping` | Shipping Info | ❌ | ✅ | ❌ | 4 |
| `returns` | Returns | ❌ | ✅ | ❌ | 5 |
| `privacy` | Privacy Policy | ❌ | ❌ | ✅ | 6 |
| `terms` | Terms of Service | ❌ | ❌ | ✅ | 7 |
| `help` | Help Center | ❌ | ✅ | ❌ | 8 |
| `size-guide` | Size Guide | ❌ | ❌ | ❌ | - |
| `careers` | Careers | ❌ | ❌ | ❌ | - |
| `cookies` | Cookie Policy | ❌ | ❌ | ✅ | 8 |
## Security Considerations
1. **HTML Sanitization**: If using HTML format, sanitize user input to prevent XSS
2. **Authorization**: Stores can only edit their own pages
3. **Published Status**: Only published pages visible to public
4. **Store Isolation**: Stores cannot see/edit other store's content
## Migration Strategy
### Initial Setup
1. **Create Platform Defaults**:
```bash
python scripts/seed/create_default_content_pages.py
```
2. **Migrate Existing Static Templates**:
- Convert existing HTML templates to database content
- Preserve existing URLs and SEO
3. **Update Routes**:
- Add generic content page route handler
- Remove individual route handlers for each page
## Future Enhancements
Possible improvements:
- **Version History**: Track content changes over time
- **Rich Text Editor**: WYSIWYG editor in admin/store panel
- **Image Management**: Upload and insert images
- **Templates**: Pre-built page templates for common pages
- **Localization**: Multi-language content support
- **Scheduled Publishing**: Publish pages at specific times
- **Content Approval**: Admin review before store pages go live
## API Reference Summary
### Admin Endpoints
```
GET /api/v1/admin/content-pages/ # List all pages
GET /api/v1/admin/content-pages/platform # List platform defaults
POST /api/v1/admin/content-pages/platform # Create platform default
GET /api/v1/admin/content-pages/{id} # Get specific page
PUT /api/v1/admin/content-pages/{id} # Update page
DELETE /api/v1/admin/content-pages/{id} # Delete page
```
### Store Endpoints
```
GET /api/v1/store/{code}/content-pages/ # List all (store + platform)
GET /api/v1/store/{code}/content-pages/overrides # List store overrides only
GET /api/v1/store/{code}/content-pages/{slug} # Get specific page
POST /api/v1/store/{code}/content-pages/ # Create store override
PUT /api/v1/store/{code}/content-pages/{id} # Update store page
DELETE /api/v1/store/{code}/content-pages/{id} # Delete store page
```
### Storefront (Public) Endpoints
```
GET /api/v1/storefront/content-pages/navigation # Get navigation links
GET /api/v1/storefront/content-pages/{slug} # Get page content
```
## Example: Complete Workflow
**1. Platform Admin Creates Defaults:**
```bash
# Create "About" page
curl -X POST /api/v1/admin/content-pages/platform \
-H "Authorization: Bearer <admin_token>" \
-d '{
"slug": "about",
"title": "About Our Marketplace",
"content": "<h1>About</h1><p>Default content...</p>",
"is_published": true
}'
```
**2. All Stores See Platform Default:**
- Store A visits: `store-a.com/about` → Shows platform default
- Store B visits: `store-b.com/about` → Shows platform default
**3. Store A Creates Override:**
```bash
curl -X POST /api/v1/store/store-a/content-pages/ \
-H "Authorization: Bearer <store_token>" \
-d '{
"slug": "about",
"title": "About Store A",
"content": "<h1>About Store A</h1><p>Custom content...</p>",
"is_published": true
}'
```
**4. Now:**
- Store A visits: `store-a.com/about` → Shows Store A custom content
- Store B visits: `store-b.com/about` → Still shows platform default
**5. Store A Reverts to Default:**
```bash
curl -X DELETE /api/v1/store/store-a/content-pages/15 \
-H "Authorization: Bearer <store_token>"
```
**6. Result:**
- Store A visits: `store-a.com/about` → Shows platform default again
This document has moved to the CMS module docs: [CMS Architecture](../modules/cms/architecture.md)

View File

@@ -1,331 +1 @@
# Email System
The email system provides multi-provider support with database-stored templates and comprehensive logging for the Orion platform.
## Overview
The email system supports:
- **Multiple Providers**: SMTP, SendGrid, Mailgun, Amazon SES
- **Multi-language Templates**: EN, FR, DE, LB (stored in database)
- **Jinja2 Templating**: Variable interpolation in subjects and bodies
- **Email Logging**: Track all sent emails for debugging and compliance
- **Debug Mode**: Log emails instead of sending during development
## Configuration
### Environment Variables
Add these settings to your `.env` file:
```env
# Provider: smtp, sendgrid, mailgun, ses
EMAIL_PROVIDER=smtp
EMAIL_FROM_ADDRESS=noreply@orion.lu
EMAIL_FROM_NAME=Orion
EMAIL_REPLY_TO=
# Behavior
EMAIL_ENABLED=true
EMAIL_DEBUG=false
# SMTP Settings (when EMAIL_PROVIDER=smtp)
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_USE_TLS=true
SMTP_USE_SSL=false
# SendGrid (when EMAIL_PROVIDER=sendgrid)
# SENDGRID_API_KEY=SG.your_api_key_here
# Mailgun (when EMAIL_PROVIDER=mailgun)
# MAILGUN_API_KEY=your_api_key_here
# MAILGUN_DOMAIN=mg.yourdomain.com
# Amazon SES (when EMAIL_PROVIDER=ses)
# AWS_ACCESS_KEY_ID=your_access_key
# AWS_SECRET_ACCESS_KEY=your_secret_key
# AWS_REGION=eu-west-1
```
### Debug Mode
Set `EMAIL_DEBUG=true` to log emails instead of sending them. This is useful during development:
```env
EMAIL_DEBUG=true
```
Emails will be logged to the console with full details (recipient, subject, body preview).
## Database Models
### EmailTemplate
Stores multi-language email templates:
| Column | Type | Description |
|--------|------|-------------|
| id | Integer | Primary key |
| code | String(100) | Template identifier (e.g., "signup_welcome") |
| language | String(5) | Language code (en, fr, de, lb) |
| name | String(255) | Human-readable name |
| description | Text | Template purpose |
| category | String(50) | AUTH, ORDERS, BILLING, SYSTEM, MARKETING |
| subject | String(500) | Email subject (supports Jinja2) |
| body_html | Text | HTML body |
| body_text | Text | Plain text fallback |
| variables | Text | JSON list of expected variables |
| is_active | Boolean | Enable/disable template |
### EmailLog
Tracks all sent emails:
| Column | Type | Description |
|--------|------|-------------|
| id | Integer | Primary key |
| template_code | String(100) | Template used (if any) |
| recipient_email | String(255) | Recipient address |
| subject | String(500) | Email subject |
| status | String(20) | PENDING, SENT, FAILED, DELIVERED, OPENED |
| sent_at | DateTime | When email was sent |
| error_message | Text | Error details if failed |
| provider | String(50) | Provider used (smtp, sendgrid, etc.) |
| store_id | Integer | Related store (optional) |
| user_id | Integer | Related user (optional) |
## Usage
### Using EmailService
```python
from app.services.email_service import EmailService
def send_welcome_email(db, user, store):
email_service = EmailService(db)
email_service.send_template(
template_code="signup_welcome",
to_email=user.email,
to_name=f"{user.first_name} {user.last_name}",
language="fr", # Falls back to "en" if not found
variables={
"first_name": user.first_name,
"merchant_name": store.name,
"store_code": store.store_code,
"login_url": f"https://orion.lu/store/{store.store_code}/dashboard",
"trial_days": 30,
"tier_name": "Essential",
},
store_id=store.id,
user_id=user.id,
related_type="signup",
)
```
### Convenience Function
```python
from app.services.email_service import send_email
send_email(
db=db,
template_code="order_confirmation",
to_email="customer@example.com",
language="en",
variables={"order_number": "ORD-001"},
)
```
### Sending Raw Emails
For one-off emails without templates:
```python
email_service = EmailService(db)
email_service.send_raw(
to_email="user@example.com",
subject="Custom Subject",
body_html="<h1>Hello</h1><p>Custom message</p>",
body_text="Hello\n\nCustom message",
)
```
## Email Templates
### Creating Templates
Templates use Jinja2 syntax for variable interpolation:
```html
<p>Hello {{ first_name }},</p>
<p>Welcome to {{ merchant_name }}!</p>
```
### Seeding Templates
Run the seed script to populate default templates:
```bash
python scripts/seed/seed_email_templates.py
```
This creates templates for:
- `signup_welcome` (en, fr, de, lb)
### Available Variables
For `signup_welcome`:
| Variable | Description |
|----------|-------------|
| first_name | User's first name |
| merchant_name | Store merchant name |
| email | User's email address |
| store_code | Store code for dashboard URL |
| login_url | Direct link to dashboard |
| trial_days | Number of trial days |
| tier_name | Subscription tier name |
## Provider Setup
### SMTP
Standard SMTP configuration:
```env
EMAIL_PROVIDER=smtp
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASSWORD=your-app-password
SMTP_USE_TLS=true
```
### SendGrid
1. Create account at [sendgrid.com](https://sendgrid.com)
2. Generate API key in Settings > API Keys
3. Configure:
```env
EMAIL_PROVIDER=sendgrid
SENDGRID_API_KEY=SG.xxxxxxxxxxxxxxxxxxxx
```
4. Install package: `pip install sendgrid`
### Mailgun
1. Create account at [mailgun.com](https://mailgun.com)
2. Add and verify your domain
3. Get API key from Domain Settings
4. Configure:
```env
EMAIL_PROVIDER=mailgun
MAILGUN_API_KEY=key-xxxxxxxxxxxxxxxx
MAILGUN_DOMAIN=mg.yourdomain.com
```
### Amazon SES
1. Set up SES in AWS Console
2. Verify sender domain/email
3. Create IAM user with SES permissions
4. Configure:
```env
EMAIL_PROVIDER=ses
AWS_ACCESS_KEY_ID=AKIAXXXXXXXXXXXXXXXX
AWS_SECRET_ACCESS_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
AWS_REGION=eu-west-1
```
5. Install package: `pip install boto3`
## Email Logging
All emails are logged to the `email_logs` table. Query examples:
```python
# Get failed emails
failed = db.query(EmailLog).filter(
EmailLog.status == EmailStatus.FAILED.value
).all()
# Get emails for a store
store_emails = db.query(EmailLog).filter(
EmailLog.store_id == store_id
).order_by(EmailLog.created_at.desc()).all()
# Get recent signup emails
signups = db.query(EmailLog).filter(
EmailLog.template_code == "signup_welcome",
EmailLog.created_at >= datetime.now() - timedelta(days=7)
).all()
```
## Language Fallback
The system automatically falls back to English if a template isn't available in the requested language:
1. Request template for "de" (German)
2. If not found, try "en" (English)
3. If still not found, return None (log error)
## Testing
Run email service tests:
```bash
pytest tests/unit/services/test_email_service.py -v
```
Test coverage includes:
- Provider abstraction (Debug, SMTP, etc.)
- Template rendering with Jinja2
- Language fallback behavior
- Email sending success/failure
- EmailLog model methods
- Template variable handling
## Architecture
```
app/services/email_service.py # Email service with provider abstraction
models/database/email.py # EmailTemplate and EmailLog models
app/core/config.py # Email configuration settings
scripts/seed/seed_email_templates.py # Template seeding script
```
### Provider Abstraction
The system uses a strategy pattern for email providers:
```
EmailProvider (ABC)
├── SMTPProvider
├── SendGridProvider
├── MailgunProvider
├── SESProvider
└── DebugProvider
```
Each provider implements the `send()` method with the same signature, making it easy to switch providers via configuration.
## Future Enhancements
Planned improvements:
1. **Email Queue**: Background task queue for high-volume sending
2. **Webhook Tracking**: Track deliveries, opens, clicks via provider webhooks
3. **Template Editor**: Admin UI for editing templates
4. **A/B Testing**: Test different email versions
5. **Scheduled Emails**: Send emails at specific times
This document has moved to the messaging module docs: [Email System](../modules/messaging/email-system.md)

View File

@@ -1,191 +1,3 @@
# Store Onboarding System
The store onboarding system is a mandatory 4-step wizard that guides new stores through the initial setup process after signup. Dashboard access is blocked until onboarding is completed.
## Overview
The onboarding wizard consists of four sequential steps:
1. **Merchant Profile Setup** - Basic merchant and contact information
2. **Letzshop API Configuration** - Connect to Letzshop marketplace
3. **Product & Order Import Configuration** - Set up CSV feed URLs
4. **Order Sync** - Import historical orders with progress tracking
## User Flow
```
Signup Complete
Redirect to /store/{code}/onboarding
Step 1: Merchant Profile
Step 2: Letzshop API (with connection test)
Step 3: Product Import Config
Step 4: Order Sync (with progress bar)
Onboarding Complete
Redirect to Dashboard
```
## Key Features
### Mandatory Completion
- Dashboard and other protected routes redirect to onboarding if not completed
- Admin can skip onboarding for support cases (via admin API)
### Step Validation
- Steps must be completed in order (no skipping ahead)
- Each step validates required fields before proceeding
### Progress Persistence
- Onboarding progress is saved in the database
- Users can resume from where they left off
- Page reload doesn't lose progress
### Connection Testing
- Step 2 includes real-time Letzshop API connection testing
- Shows success/failure status before saving credentials
### Historical Import
- Step 4 triggers a background job for order import
- Real-time progress bar with polling (2-second intervals)
- Shows order count as import progresses
## Database Model
### StoreOnboarding Table
| Column | Type | Description |
|--------|------|-------------|
| id | Integer | Primary key |
| store_id | Integer | Foreign key to stores (unique) |
| status | String(20) | not_started, in_progress, completed, skipped |
| current_step | String(30) | Current step identifier |
| step_*_completed | Boolean | Completion flag per step |
| step_*_completed_at | DateTime | Completion timestamp per step |
| skipped_by_admin | Boolean | Admin override flag |
| skipped_reason | Text | Reason for skip (admin) |
### Onboarding Steps Enum
```python
class OnboardingStep(str, enum.Enum):
MERCHANT_PROFILE = "merchant_profile"
LETZSHOP_API = "letzshop_api"
PRODUCT_IMPORT = "product_import"
ORDER_SYNC = "order_sync"
```
## API Endpoints
All endpoints are under `/api/v1/store/onboarding/`:
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/status` | Get full onboarding status |
| GET | `/step/merchant-profile` | Get merchant profile data |
| POST | `/step/merchant-profile` | Save merchant profile |
| POST | `/step/letzshop-api/test` | Test API connection |
| POST | `/step/letzshop-api` | Save API credentials |
| GET | `/step/product-import` | Get import config |
| POST | `/step/product-import` | Save import config |
| POST | `/step/order-sync/trigger` | Start historical import |
| GET | `/step/order-sync/progress/{job_id}` | Get import progress |
| POST | `/step/order-sync/complete` | Complete onboarding |
## Integration Points
### Signup Flow
When a store is created during signup, an onboarding record is automatically created:
```python
# In platform_signup_service.py
onboarding_service = OnboardingService(db)
onboarding_service.create_onboarding(store.id)
```
### Route Protection
Protected routes check onboarding status and redirect if not completed:
```python
# In store_pages.py
onboarding_service = OnboardingService(db)
if not onboarding_service.is_completed(current_user.token_store_id):
return RedirectResponse(f"/store/{store_code}/onboarding")
```
### Historical Import
Step 4 uses the existing `LetzshopHistoricalImportJob` infrastructure:
```python
order_service = LetzshopOrderService(db)
job = order_service.create_historical_import_job(store_id, user_id)
```
## Frontend Implementation
### Template
`app/templates/store/onboarding.html`:
- Standalone page (doesn't use store base template)
- Progress indicator with step circles
- Animated transitions between steps
- Real-time sync progress bar
### JavaScript
`static/store/js/onboarding.js`:
- Alpine.js component
- API calls for each step
- Connection test functionality
- Progress polling for order sync
## Admin Skip Capability
For support cases, admins can skip onboarding:
```python
onboarding_service.skip_onboarding(
store_id=store_id,
admin_user_id=admin_user_id,
reason="Manual setup required for migration"
)
```
This sets `skipped_by_admin=True` and allows dashboard access without completing all steps.
## Files
| File | Purpose |
|------|---------|
| `models/database/onboarding.py` | Database model and enums |
| `models/schema/onboarding.py` | Pydantic schemas |
| `app/services/onboarding_service.py` | Business logic |
| `app/api/v1/store/onboarding.py` | API endpoints |
| `app/routes/store_pages.py` | Page routes and redirects |
| `app/templates/store/onboarding.html` | Frontend template |
| `static/store/js/onboarding.js` | Alpine.js component |
| `alembic/versions/m1b2c3d4e5f6_add_store_onboarding_table.py` | Migration |
## Testing
Run the onboarding tests:
```bash
pytest tests/integration/api/v1/store/test_onboarding.py -v
```
## Configuration
No additional configuration is required. The onboarding system uses existing configurations:
- Letzshop API: Uses `LetzshopCredentialsService`
- Order Import: Uses `LetzshopOrderService`
- Email: Uses `EmailService` for welcome email (sent after signup)
This document has moved to the tenancy module docs: [Store Onboarding](../modules/tenancy/onboarding.md)

View File

@@ -1,631 +1,3 @@
# Subscription & Billing System
The platform provides a comprehensive subscription and billing system for managing merchant subscriptions, feature-based usage limits, and payments through Stripe.
## Overview
The billing system enables:
- **Subscription Tiers**: Database-driven tier definitions with configurable feature limits
- **Feature Provider Pattern**: Modules declare features and usage via `FeatureProviderProtocol`, aggregated by `FeatureAggregatorService`
- **Dynamic Usage Tracking**: Quantitative features (orders, products, team members) tracked per merchant with dynamic limits from `TierFeatureLimit`
- **Binary Feature Gating**: Toggle-based features (analytics, API access, white-label) controlled per tier
- **Merchant-Level Billing**: Subscriptions are per merchant+platform, not per store
- **Stripe Integration**: Checkout sessions, customer portal, and webhook handling
- **Add-ons**: Optional purchasable items (domains, SSL, email packages)
- **Capacity Forecasting**: Growth trends and scaling recommendations
- **Background Jobs**: Automated subscription lifecycle management
## Architecture
### Key Concepts
The billing system uses a **feature provider pattern** where:
1. **`TierFeatureLimit`** replaces hardcoded tier columns (`orders_per_month`, `products_limit`, `team_members`). Each feature limit is a row linking a tier to a feature code with a `limit_value`.
2. **`MerchantFeatureOverride`** provides per-merchant exceptions to tier defaults.
3. **Module feature providers** implement `FeatureProviderProtocol` to supply current usage data.
4. **`FeatureAggregatorService`** collects usage from all providers and combines it with tier limits to produce `FeatureSummary` records.
```
┌──────────────────────────────────────────────────────────────┐
│ Frontend Page Request │
│ (Store Billing, Admin Subscriptions, Admin Store Detail) │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ FeatureAggregatorService │
│ (app/modules/billing/services/feature_service.py) │
│ │
│ • Collects feature providers from all enabled modules │
│ • Queries TierFeatureLimit for limit values │
│ • Queries MerchantFeatureOverride for per-merchant limits │
│ • Calls provider.get_current_usage() for live counts │
│ • Returns FeatureSummary[] with current/limit/percentage │
└──────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ catalog module │ │ orders module │ │ tenancy module │
│ products count │ │ orders count │ │ team members │
└────────────────┘ └────────────────┘ └────────────────┘
```
### Database Models
All subscription models are in `app/modules/billing/models/`:
| Model | Purpose |
|-------|---------|
| `SubscriptionTier` | Tier definitions with Stripe price IDs and feature codes |
| `TierFeatureLimit` | Per-tier feature limits (feature_code + limit_value) |
| `MerchantSubscription` | Per-merchant+platform subscription status |
| `MerchantFeatureOverride` | Per-merchant feature limit overrides |
| `AddOnProduct` | Purchasable add-ons (domains, SSL, email) |
| `StoreAddOn` | Add-ons purchased by each store |
| `StripeWebhookEvent` | Idempotency tracking for webhooks |
| `BillingHistory` | Invoice and payment history |
| `CapacitySnapshot` | Daily platform capacity metrics for forecasting |
### Feature Types
Features come in two types:
| Type | Description | Example |
|------|-------------|---------|
| **Quantitative** | Has a numeric limit with usage tracking | `max_products` (limit: 200, current: 150) |
| **Binary** | Toggle-based, either enabled or disabled | `analytics_dashboard` (enabled/disabled) |
### FeatureSummary Dataclass
The core data structure returned by the feature system:
```python
@dataclass
class FeatureSummary:
code: str # e.g., "max_products"
name_key: str # i18n key for display name
limit: int | None # None = unlimited
current: int # Current usage count
remaining: int # Remaining before limit
percent_used: float # 0.0 to 100.0
feature_type: str # "quantitative" or "binary"
scope: str # "tier" or "merchant_override"
```
### Services
| Service | Location | Purpose |
|---------|----------|---------|
| `FeatureAggregatorService` | `app/modules/billing/services/feature_service.py` | Aggregates usage from module providers, resolves tier limits + overrides |
| `BillingService` | `app/modules/billing/services/billing_service.py` | Subscription operations, checkout, portal |
| `SubscriptionService` | `app/modules/billing/services/subscription_service.py` | Subscription CRUD, tier lookups |
| `AdminSubscriptionService` | `app/modules/billing/services/admin_subscription_service.py` | Admin subscription management |
| `StripeService` | `app/modules/billing/services/stripe_service.py` | Core Stripe API operations |
| `CapacityForecastService` | `app/modules/billing/services/capacity_forecast_service.py` | Growth trends, projections |
### Background Tasks
| Task | Location | Schedule | Purpose |
|------|----------|----------|---------|
| `reset_period_counters` | `app/modules/billing/tasks/subscription.py` | Daily | Reset order counters at period end |
| `check_trial_expirations` | `app/modules/billing/tasks/subscription.py` | Daily | Expire trials without payment method |
| `sync_stripe_status` | `app/modules/billing/tasks/subscription.py` | Hourly | Sync status with Stripe |
| `cleanup_stale_subscriptions` | `app/modules/billing/tasks/subscription.py` | Weekly | Clean up old cancelled subscriptions |
| `capture_capacity_snapshot` | `app/modules/billing/tasks/subscription.py` | Daily | Capture capacity metrics snapshot |
## API Endpoints
### Store Billing API
Base: `/api/v1/store/billing`
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/billing/subscription` | GET | Current subscription status |
| `/billing/tiers` | GET | Available tiers for upgrade |
| `/billing/usage` | GET | Dynamic usage metrics (from feature providers) |
| `/billing/checkout` | POST | Create Stripe checkout session |
| `/billing/portal` | POST | Create Stripe customer portal session |
| `/billing/invoices` | GET | Invoice history |
| `/billing/upcoming-invoice` | GET | Preview next invoice |
| `/billing/change-tier` | POST | Upgrade/downgrade tier |
| `/billing/addons` | GET | Available add-on products |
| `/billing/my-addons` | GET | Store's purchased add-ons |
| `/billing/addons/purchase` | POST | Purchase an add-on |
| `/billing/addons/{id}` | DELETE | Cancel an add-on |
| `/billing/cancel` | POST | Cancel subscription |
| `/billing/reactivate` | POST | Reactivate cancelled subscription |
The `/billing/usage` endpoint returns `UsageMetric[]`:
```json
[
{
"name": "Products",
"current": 150,
"limit": 200,
"percentage": 75.0,
"is_unlimited": false,
"is_at_limit": false,
"is_approaching_limit": true
}
]
```
### Admin Subscription API
Base: `/api/v1/admin/subscriptions`
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/tiers` | GET | List all subscription tiers |
| `/tiers` | POST | Create a new tier |
| `/tiers/{code}` | PATCH | Update a tier |
| `/tiers/{code}` | DELETE | Delete a tier |
| `/stats` | GET | Subscription statistics |
| `/merchants/{id}/platforms/{pid}` | GET | Get merchant subscription |
| `/merchants/{id}/platforms/{pid}` | PUT | Update merchant subscription |
| `/store/{store_id}` | GET | Convenience: get subscription + usage for a store |
### Admin Feature Management API
Base: `/api/v1/admin/subscriptions/features`
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/catalog` | GET | Feature catalog grouped by category |
| `/tiers/{code}/limits` | GET | Get feature limits for a tier |
| `/tiers/{code}/limits` | PUT | Upsert feature limits for a tier |
| `/merchants/{id}/overrides` | GET | Get merchant feature overrides |
| `/merchants/{id}/overrides` | PUT | Upsert merchant feature overrides |
The **feature catalog** returns features grouped by category:
```json
{
"features": {
"analytics": [
{"code": "basic_analytics", "name": "Basic Analytics", "feature_type": "binary", "category": "analytics"},
{"code": "analytics_dashboard", "name": "Analytics Dashboard", "feature_type": "binary", "category": "analytics"}
],
"limits": [
{"code": "max_products", "name": "Product Limit", "feature_type": "quantitative", "category": "limits"},
{"code": "max_orders_per_month", "name": "Orders per Month", "feature_type": "quantitative", "category": "limits"}
]
}
}
```
**Tier feature limits** use `TierFeatureLimitEntry[]` format:
```json
[
{"feature_code": "max_products", "limit_value": 200, "enabled": true},
{"feature_code": "max_orders_per_month", "limit_value": 100, "enabled": true},
{"feature_code": "analytics_dashboard", "limit_value": null, "enabled": true}
]
```
### Admin Store Convenience Endpoint
`GET /api/v1/admin/subscriptions/store/{store_id}` resolves a store to its merchant and returns subscription + usage in one call:
```json
{
"subscription": {
"tier": "professional",
"status": "active",
"period_start": "2026-01-01T00:00:00Z",
"period_end": "2026-02-01T00:00:00Z"
},
"tier": {
"code": "professional",
"name": "Professional",
"price_monthly_cents": 9900
},
"features": [
{
"name": "Products",
"current": 150,
"limit": null,
"percentage": 0,
"is_unlimited": true,
"is_at_limit": false,
"is_approaching_limit": false
},
{
"name": "Orders per Month",
"current": 320,
"limit": 500,
"percentage": 64.0,
"is_unlimited": false,
"is_at_limit": false,
"is_approaching_limit": false
}
]
}
```
### Admin Platform Health API
Capacity endpoints under `/api/v1/admin/platform-health`:
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/platform-health/health` | GET | Full platform health report |
| `/platform-health/capacity` | GET | Capacity-focused metrics |
| `/platform-health/subscription-capacity` | GET | Subscription-based capacity vs usage |
| `/platform-health/trends` | GET | Growth trends over time |
| `/platform-health/recommendations` | GET | Scaling recommendations |
| `/platform-health/snapshot` | POST | Manually capture capacity snapshot |
## Subscription Tiers
### Tier Structure
Tiers are stored in the `subscription_tiers` table. Feature limits are stored separately in `tier_feature_limits`:
```
SubscriptionTier (essential)
├── TierFeatureLimit: max_products = 200
├── TierFeatureLimit: max_orders_per_month = 100
├── TierFeatureLimit: max_team_members = 1
├── TierFeatureLimit: basic_support (binary, enabled)
└── TierFeatureLimit: basic_analytics (binary, enabled)
SubscriptionTier (professional)
├── TierFeatureLimit: max_products = NULL (unlimited)
├── TierFeatureLimit: max_orders_per_month = 500
├── TierFeatureLimit: max_team_members = 3
├── TierFeatureLimit: priority_support (binary, enabled)
├── TierFeatureLimit: analytics_dashboard (binary, enabled)
└── ...
```
### Admin Tier Management
Administrators manage tiers at `/admin/subscription-tiers`:
**Capabilities:**
- View all tiers with stats (total, active, public, MRR)
- Create/edit tiers with pricing and Stripe IDs
- Activate/deactivate tiers
- Assign features to tiers via slide-over panel with:
- Binary features: checkbox toggles grouped by category
- Quantitative features: checkbox + numeric limit input
- Select all / Deselect all per category
**Feature Panel API Flow:**
1. Open panel: `GET /admin/subscriptions/features/catalog` (all available features)
2. Open panel: `GET /admin/subscriptions/features/tiers/{code}/limits` (current tier limits)
3. Save: `PUT /admin/subscriptions/features/tiers/{code}/limits` (upsert limits)
### Per-Merchant Overrides
Admins can override tier limits for individual merchants via the subscription edit modal:
1. Open edit modal for a subscription
2. Fetches feature catalog + current merchant overrides
3. Shows each quantitative feature with override input (or "Tier default" placeholder)
4. Save sends `PUT /admin/subscriptions/features/merchants/{id}/overrides`
## Frontend Pages
### Store Billing Page
**Location:** `/store/{store_code}/billing`
**Template:** `app/modules/billing/templates/billing/store/billing.html`
**JS:** `app/modules/billing/static/store/js/billing.js`
The billing page fetches usage metrics dynamically from `GET /store/billing/usage` and renders them with Alpine.js:
```html
<template x-for="metric in usageMetrics" :key="metric.name">
<div class="mb-4">
<div class="flex justify-between text-sm mb-1">
<span x-text="metric.name"></span>
<span x-text="metric.is_unlimited ? metric.current + ' (Unlimited)' : metric.current + ' / ' + metric.limit"></span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div class="h-2 rounded-full"
:class="metric.percentage >= 90 ? 'bg-red-600' : metric.percentage >= 70 ? 'bg-yellow-600' : 'bg-purple-600'"
:style="`width: ${Math.min(100, metric.percentage || 0)}%`"></div>
</div>
</div>
</template>
```
**Page sections:**
1. **Current Plan**: Tier name, status, next billing date
2. **Usage Meters**: Dynamic usage bars from feature providers
3. **Change Plan**: Tier cards showing `feature_codes` list
4. **Payment Method**: Link to Stripe portal
5. **Invoice History**: Recent invoices with PDF links
6. **Add-ons**: Available and purchased add-ons
### Admin Subscriptions Page
**Location:** `/admin/subscriptions`
**Template:** `app/modules/billing/templates/billing/admin/subscriptions.html`
**JS:** `app/modules/billing/static/admin/js/subscriptions.js`
Lists all merchant subscriptions with:
- Tier, status, merchant info, period dates
- Features count column (from `feature_codes.length`)
- Edit modal with dynamic feature override editor
### Admin Subscription Tiers Page
**Location:** `/admin/subscription-tiers`
**Template:** `app/modules/billing/templates/billing/admin/subscription-tiers.html`
**JS:** `app/modules/billing/static/admin/js/subscription-tiers.js`
Manages tier definitions with:
- Stats cards (total, active, public, MRR)
- Tier table (code, name, pricing, features count, status)
- Create/edit modal (pricing, Stripe IDs, description, toggles)
- Feature assignment slide-over panel (binary toggles + quantitative limit inputs)
### Merchant Subscription Detail Page
**Template:** `app/modules/billing/templates/billing/merchant/subscription-detail.html`
Shows subscription details with plan limits rendered dynamically from `tier.feature_limits`:
```html
<template x-for="fl in (subscription?.tier?.feature_limits || [])" :key="fl.feature_code">
<div class="p-4 bg-gray-50 rounded-lg">
<p class="text-sm text-gray-500" x-text="fl.feature_code.replace(/_/g, ' ')"></p>
<p class="text-xl font-bold" x-text="fl.limit_value || 'Unlimited'"></p>
</div>
</template>
```
### Admin Store Detail Page
**Template:** `app/modules/tenancy/templates/tenancy/admin/store-detail.html`
**JS:** `app/modules/tenancy/static/admin/js/store-detail.js`
The subscription section uses the convenience endpoint `GET /admin/subscriptions/store/{store_id}` to load subscription + usage metrics in one call, rendering dynamic usage bars.
## Stripe Integration
### Configuration
Required environment variables:
```bash
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_TRIAL_DAYS=30 # Optional, default trial period
```
### Setup Guide
#### Step 1: Get API Keys
1. Go to [Stripe Dashboard](https://dashboard.stripe.com/apikeys)
2. Copy your **Publishable key** (`pk_test_...` or `pk_live_...`)
3. Copy your **Secret key** (`sk_test_...` or `sk_live_...`)
#### Step 2: Create Webhook Endpoint
1. Go to [Stripe Webhooks](https://dashboard.stripe.com/webhooks)
2. Click **Add endpoint**
3. Enter your endpoint URL: `https://yourdomain.com/api/v1/webhooks/stripe`
4. Select events to listen to:
- `checkout.session.completed`
- `customer.subscription.created`
- `customer.subscription.updated`
- `customer.subscription.deleted`
- `invoice.paid`
- `invoice.payment_failed`
5. Click **Add endpoint**
6. Copy the **Signing secret** (`whsec_...`) - this is your `STRIPE_WEBHOOK_SECRET`
#### Step 3: Local Development with Stripe CLI
For local testing, use the [Stripe CLI](https://stripe.com/docs/stripe-cli):
```bash
# Install Stripe CLI
brew install stripe/stripe-cli/stripe # macOS
# or download from https://github.com/stripe/stripe-cli/releases
# Login to Stripe
stripe login
# Forward webhooks to your local server
stripe listen --forward-to localhost:8000/api/v1/webhooks/stripe
# The CLI will display a webhook signing secret (whsec_...)
# Use this as STRIPE_WEBHOOK_SECRET for local development
```
#### Step 4: Create Products & Prices in Stripe
Create subscription products for each tier:
1. Go to [Stripe Products](https://dashboard.stripe.com/products)
2. Create products for each tier (Essential, Professional, Business, Enterprise)
3. Add monthly and annual prices for each
4. Copy the Price IDs (`price_...`) and update your tier configuration
### Webhook Events
The system handles these Stripe events:
| Event | Handler |
|-------|---------|
| `checkout.session.completed` | Activates subscription, links customer |
| `customer.subscription.updated` | Updates tier, status, period |
| `customer.subscription.deleted` | Marks subscription cancelled |
| `invoice.paid` | Records payment in billing history |
| `invoice.payment_failed` | Marks past due, increments retry count |
### Webhook Endpoint
Webhooks are received at `/api/v1/webhooks/stripe`:
```python
# Uses signature verification for security
event = stripe_service.construct_event(payload, stripe_signature)
handler = StripeWebhookHandler()
result = handler.handle_event(db, event)
```
The handler implements **idempotency** via `StripeWebhookEvent` records. Duplicate events (same `event_id`) are skipped.
## Add-ons
### Available Add-ons
| Code | Name | Category | Price |
|------|------|----------|-------|
| `domain` | Custom Domain | domain | €15/year |
| `ssl_premium` | Premium SSL | ssl | €49/year |
| `email_5` | 5 Email Addresses | email | €5/month |
| `email_10` | 10 Email Addresses | email | €9/month |
| `email_25` | 25 Email Addresses | email | €19/month |
### Purchase Flow
1. Store selects add-on on billing page
2. For domains: enter domain name, validate availability
3. Create Stripe checkout session with add-on price
4. On webhook success: create `StoreAddOn` record
## Capacity Forecasting
### Subscription-Based Capacity
Track theoretical vs actual capacity:
```python
capacity = platform_health_service.get_subscription_capacity(db)
# Returns:
{
"total_subscriptions": 150,
"tier_distribution": {
"essential": 80,
"professional": 50,
"business": 18,
"enterprise": 2
},
"products": {
"actual": 125000,
"theoretical_limit": 500000,
"utilization_percent": 25.0,
"headroom": 375000
},
"orders_monthly": {
"actual": 45000,
"theoretical_limit": 300000,
"utilization_percent": 15.0
}
}
```
### Growth Trends
Analyze growth over time:
```python
trends = capacity_forecast_service.get_growth_trends(db, days=30)
# Returns growth rates, daily projections, monthly projections
```
### Scaling Recommendations
```python
recommendations = capacity_forecast_service.get_scaling_recommendations(db)
# Returns:
[
{
"category": "capacity",
"severity": "warning",
"title": "Product capacity approaching limit",
"description": "Currently at 85% of theoretical product capacity",
"action": "Consider upgrading store tiers or adding capacity"
}
]
```
### Infrastructure Scaling Reference
| Clients | vCPU | RAM | Storage | Database | Monthly Cost |
|---------|------|-----|---------|----------|--------------|
| 1-50 | 2 | 4GB | 100GB | SQLite | €30 |
| 50-100 | 4 | 8GB | 250GB | PostgreSQL | €80 |
| 100-300 | 4 | 16GB | 500GB | PostgreSQL | €150 |
| 300-500 | 8 | 32GB | 1TB | PostgreSQL + Redis | €350 |
| 500-1000 | 16 | 64GB | 2TB | PostgreSQL + Redis | €700 |
| 1000+ | 32+ | 128GB+ | 4TB+ | PostgreSQL cluster | €1,500+ |
## Exception Handling
Custom exceptions for billing operations (`app/modules/billing/exceptions.py`):
| Exception | HTTP Status | Description |
|-----------|-------------|-------------|
| `PaymentSystemNotConfiguredException` | 503 | Stripe not configured |
| `TierNotFoundException` | 404 | Invalid tier code |
| `StripePriceNotConfiguredException` | 400 | No Stripe price for tier |
| `NoActiveSubscriptionException` | 400 | Operation requires subscription |
| `SubscriptionNotCancelledException` | 400 | Cannot reactivate active subscription |
## Testing
Unit tests for the billing system:
```bash
# Run billing service tests
pytest tests/unit/services/test_billing_service.py -v
# Run webhook handler tests
pytest tests/unit/services/test_stripe_webhook_handler.py -v
# Run feature service tests
pytest tests/unit/services/test_feature_service.py -v
# Run usage service tests
pytest tests/unit/services/test_usage_service.py -v
```
### Test Coverage
- `BillingService`: Tier queries, invoices, add-ons
- `StripeWebhookHandler`: Event idempotency, checkout completion, status mapping
- `FeatureService`: Feature aggregation, tier limits, merchant overrides
- `UsageService`: Usage tracking, limit checks
## Security Considerations
- Webhook signatures verified before processing
- Idempotency keys prevent duplicate event processing
- Customer portal links are session-based and expire
- Stripe API key stored securely in environment variables
- Background tasks run with database session isolation
## Related Documentation
- [Feature Gating System](../implementation/feature-gating-system.md) - Feature access control and UI integration
- [Metrics Provider Pattern](../architecture/metrics-provider-pattern.md) - Protocol-based metrics from modules
- [Capacity Monitoring](../operations/capacity-monitoring.md) - Detailed monitoring guide
- [Capacity Planning](../architecture/capacity-planning.md) - Infrastructure sizing
- [Stripe Integration](../deployment/stripe-integration.md) - Payment setup
- [Subscription Tier Management](../guides/subscription-tier-management.md) - User guide for tier management
This document has moved to the billing module docs: [Subscription System](../modules/billing/subscription-system.md)

View File

@@ -1,502 +1 @@
# Hosting Module - User Journeys
## Personas
| # | Persona | Role / Auth | Description |
|---|---------|-------------|-------------|
| 1 | **Platform Admin** | `admin` role | Manages the POC → live website pipeline, tracks client services, monitors renewals |
| 2 | **Prospect** | No auth (receives proposal link) | Views their POC website preview via a shared link |
!!! note "Admin-only module"
The hosting module is primarily an admin-only module. The only non-admin page is the
**POC Viewer** — a public preview page that shows the prospect's POC website with a
HostWizard banner. Prospects do not have accounts until their proposal is accepted, at
which point a Merchant account is created for them.
---
## Lifecycle Overview
The hosting module manages the complete POC → live website pipeline:
```mermaid
flowchart TD
A[Prospect identified] --> B[Create Hosted Site]
B --> C[Status: DRAFT]
C --> D[Build POC website via CMS]
D --> E[Mark POC Ready]
E --> F[Status: POC_READY]
F --> G[Send Proposal to prospect]
G --> H[Status: PROPOSAL_SENT]
H --> I{Prospect accepts?}
I -->|Yes| J[Accept Proposal]
J --> K[Status: ACCEPTED]
K --> L[Merchant account created]
L --> M[Go Live with domain]
M --> N[Status: LIVE]
I -->|No| O[Cancel]
O --> P[Status: CANCELLED]
N --> Q{Issues?}
Q -->|Payment issues| R[Suspend]
R --> S[Status: SUSPENDED]
S --> T[Reactivate → LIVE]
Q -->|Client leaves| O
```
### Status Transitions
| From | Allowed Targets |
|------|----------------|
| `draft` | `poc_ready`, `cancelled` |
| `poc_ready` | `proposal_sent`, `cancelled` |
| `proposal_sent` | `accepted`, `cancelled` |
| `accepted` | `live`, `cancelled` |
| `live` | `suspended`, `cancelled` |
| `suspended` | `live`, `cancelled` |
| `cancelled` | _(terminal)_ |
---
## Dev URLs (localhost:9999)
The dev server uses path-based platform routing: `http://localhost:9999/platforms/hosting/...`
### 1. Admin Pages
Login as: `admin@orion.lu` or `samir.boulahtit@gmail.com`
| Page | Dev URL |
|------|---------|
| Dashboard | `http://localhost:9999/platforms/hosting/admin/hosting` |
| Sites List | `http://localhost:9999/platforms/hosting/admin/hosting/sites` |
| New Site | `http://localhost:9999/platforms/hosting/admin/hosting/sites/new` |
| Site Detail | `http://localhost:9999/platforms/hosting/admin/hosting/sites/{site_id}` |
| Client Services | `http://localhost:9999/platforms/hosting/admin/hosting/clients` |
### 2. Public Pages
| Page | Dev URL |
|------|---------|
| POC Viewer | `http://localhost:9999/platforms/hosting/hosting/sites/{site_id}/preview` |
### 3. Admin API Endpoints
**Sites** (prefix: `/platforms/hosting/api/admin/hosting/`):
| Method | Endpoint | Dev URL |
|--------|----------|---------|
| GET | list sites | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites` |
| GET | site detail | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}` |
| POST | create site | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites` |
| POST | create from prospect | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/from-prospect/{prospect_id}` |
| PUT | update site | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}` |
| DELETE | delete site | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}` |
**Lifecycle** (prefix: `/platforms/hosting/api/admin/hosting/`):
| Method | Endpoint | Dev URL |
|--------|----------|---------|
| POST | mark POC ready | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/mark-poc-ready` |
| POST | send proposal | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/send-proposal` |
| POST | accept proposal | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/accept` |
| POST | go live | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/go-live` |
| POST | suspend | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/suspend` |
| POST | cancel | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/cancel` |
**Client Services** (prefix: `/platforms/hosting/api/admin/hosting/`):
| Method | Endpoint | Dev URL |
|--------|----------|---------|
| GET | list services | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{site_id}/services` |
| POST | create service | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{site_id}/services` |
| PUT | update service | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{site_id}/services/{id}` |
| DELETE | delete service | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{site_id}/services/{id}` |
**Stats** (prefix: `/platforms/hosting/api/admin/hosting/`):
| Method | Endpoint | Dev URL |
|--------|----------|---------|
| GET | dashboard stats | `http://localhost:9999/platforms/hosting/api/admin/hosting/stats/dashboard` |
---
## Production URLs (hostwizard.lu)
In production, the platform uses **domain-based routing**.
### Admin Pages & API
| Page / Endpoint | Production URL |
|-----------------|----------------|
| Dashboard | `https://hostwizard.lu/admin/hosting` |
| Sites | `https://hostwizard.lu/admin/hosting/sites` |
| New Site | `https://hostwizard.lu/admin/hosting/sites/new` |
| Site Detail | `https://hostwizard.lu/admin/hosting/sites/{id}` |
| Client Services | `https://hostwizard.lu/admin/hosting/clients` |
| API - Sites | `GET https://hostwizard.lu/api/admin/hosting/sites` |
| API - Stats | `GET https://hostwizard.lu/api/admin/hosting/stats/dashboard` |
### Public Pages
| Page | Production URL |
|------|----------------|
| POC Viewer | `https://hostwizard.lu/hosting/sites/{site_id}/preview` |
---
## Data Model
### Hosted Site
```
HostedSite
├── id (PK)
├── store_id (FK → stores.id, unique) # The CMS-powered website
├── prospect_id (FK → prospects.id, nullable) # Origin prospect
├── status: draft | poc_ready | proposal_sent | accepted | live | suspended | cancelled
├── business_name (str)
├── contact_name, contact_email, contact_phone
├── proposal_sent_at, proposal_accepted_at, went_live_at (datetime)
├── proposal_notes (text)
├── live_domain (str, unique)
├── internal_notes (text)
├── created_at, updated_at
└── Relationships: store, prospect, client_services
```
### Client Service
```
ClientService
├── id (PK)
├── hosted_site_id (FK → hosted_sites.id, CASCADE)
├── service_type: domain | email | ssl | hosting | website_maintenance
├── name (str) # e.g., "acme.lu domain", "5 mailboxes"
├── status: pending | active | suspended | expired | cancelled
├── billing_period: monthly | annual | one_time
├── price_cents (int), currency (str, default EUR)
├── addon_product_id (FK, nullable) # Link to billing product
├── domain_name, registrar # Domain-specific
├── mailbox_count # Email-specific
├── expires_at, period_start, period_end, auto_renew
├── notes (text)
└── created_at, updated_at
```
---
## User Journeys
### Journey 1: Create Hosted Site from Prospect
**Persona:** Platform Admin
**Goal:** Convert a qualified prospect into a hosted site with a POC website
**Prerequisite:** A prospect exists in the prospecting module (see [Prospecting Journeys](prospecting.md))
```mermaid
flowchart TD
A[View prospect in prospecting module] --> B[Click 'Create Hosted Site from Prospect']
B --> C[HostedSite created with status DRAFT]
C --> D[Store auto-created on hosting platform]
D --> E[Contact info pre-filled from prospect]
E --> F[Navigate to site detail]
F --> G[Build POC website via CMS editor]
```
**Steps:**
1. Create hosted site from prospect:
- API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites/from-prospect/{prospect_id}`
- API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites/from-prospect/{prospect_id}`
2. This automatically:
- Creates a Store on the hosting platform
- Creates a HostedSite record linked to the Store and Prospect
- Pre-fills business_name, contact_name, contact_email, contact_phone from prospect data
3. View the new site:
- Dev: `http://localhost:9999/platforms/hosting/admin/hosting/sites/{site_id}`
- Prod: `https://hostwizard.lu/admin/hosting/sites/{site_id}`
4. Click the Store link to open the CMS editor and build the POC website
---
### Journey 2: Create Hosted Site Manually
**Persona:** Platform Admin
**Goal:** Create a hosted site without an existing prospect (e.g., direct referral)
```mermaid
flowchart TD
A[Navigate to New Site page] --> B[Fill in business details]
B --> C[Submit form]
C --> D[HostedSite + Store created]
D --> E[Navigate to site detail]
E --> F[Build POC website]
```
**Steps:**
1. Navigate to New Site form:
- Dev: `http://localhost:9999/platforms/hosting/admin/hosting/sites/new`
- Prod: `https://hostwizard.lu/admin/hosting/sites/new`
2. Create the site:
- API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites`
- API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites`
- Body: `{ "business_name": "Boulangerie du Parc", "contact_name": "Jean Müller", "contact_email": "jean@boulangerie-parc.lu", "contact_phone": "+352 26 123 456" }`
3. A Store is auto-created with subdomain `boulangerie-du-parc` on the hosting platform
---
### Journey 3: POC → Proposal Flow
**Persona:** Platform Admin
**Goal:** Build a POC website, mark it ready, and send a proposal to the prospect
```mermaid
flowchart TD
A[Site is DRAFT] --> B[Build POC website via CMS]
B --> C[Mark POC Ready]
C --> D[Site is POC_READY]
D --> E[Preview the POC site]
E --> F[Send Proposal with notes]
F --> G[Site is PROPOSAL_SENT]
G --> H[Share preview link with prospect]
```
**Steps:**
1. Build the POC website using the Store's CMS editor (linked from site detail page)
2. When the POC is ready, mark it:
- API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/mark-poc-ready`
- API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites/{id}/mark-poc-ready`
3. Preview the POC site (public link, no auth needed):
- Dev: `http://localhost:9999/platforms/hosting/hosting/sites/{id}/preview`
- Prod: `https://hostwizard.lu/hosting/sites/{id}/preview`
4. Send proposal to the prospect:
- API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/send-proposal`
- API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites/{id}/send-proposal`
- Body: `{ "notes": "Custom website with 5 pages, domain registration included" }`
5. Share the preview link with the prospect via email
!!! info "POC Viewer"
The POC Viewer page renders the Store's storefront in an iframe with a teal
HostWizard banner at the top. It only works for sites with status `poc_ready`
or `proposal_sent`. Once the site goes live, the preview is disabled.
---
### Journey 4: Accept Proposal & Create Merchant
**Persona:** Platform Admin
**Goal:** When a prospect accepts, create their merchant account and subscription
```mermaid
flowchart TD
A[Prospect accepts proposal] --> B{Existing merchant?}
B -->|Yes| C[Link to existing merchant]
B -->|No| D[Auto-create merchant + owner account]
C --> E[Accept Proposal]
D --> E
E --> F[Site is ACCEPTED]
F --> G[Store reassigned to merchant]
G --> H[Subscription created on hosting platform]
H --> I[Prospect marked as CONVERTED]
```
**Steps:**
1. Accept the proposal (auto-creates merchant if no merchant_id provided):
- API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/accept`
- API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites/{id}/accept`
- Body: `{}` (auto-create merchant) or `{ "merchant_id": 5 }` (link to existing)
2. This automatically:
- Creates a new Merchant from contact info (name, email, phone)
- Creates a store owner account with a temporary password
- Reassigns the Store from the system merchant to the new merchant
- Creates a MerchantSubscription on the hosting platform (essential tier)
- Marks the linked prospect as CONVERTED (if prospect_id is set)
3. View the updated site detail:
- Dev: `http://localhost:9999/platforms/hosting/admin/hosting/sites/{id}`
- Prod: `https://hostwizard.lu/admin/hosting/sites/{id}`
!!! warning "Merchant account credentials"
When accepting without an existing `merchant_id`, a new merchant owner account is
created with a temporary password. The admin should communicate these credentials
to the client so they can log in and self-edit their website via the CMS.
---
### Journey 5: Go Live with Custom Domain
**Persona:** Platform Admin
**Goal:** Assign a production domain to the website and make it live
```mermaid
flowchart TD
A[Site is ACCEPTED] --> B[Configure DNS for client domain]
B --> C[Go Live with domain]
C --> D[Site is LIVE]
D --> E[StoreDomain created]
E --> F[Website accessible at client domain]
```
**Steps:**
1. Ensure DNS is configured for the client's domain (A/AAAA records pointing to the server)
2. Go live:
- API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/go-live`
- API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites/{id}/go-live`
- Body: `{ "domain": "boulangerie-parc.lu" }`
3. This automatically:
- Sets `went_live_at` timestamp
- Creates a StoreDomain record (primary) for the domain
- Sets `live_domain` on the hosted site
4. The website is now accessible at `https://boulangerie-parc.lu`
---
### Journey 6: Add Client Services
**Persona:** Platform Admin
**Goal:** Track operational services (domains, email, SSL, hosting) for a client
```mermaid
flowchart TD
A[Open site detail] --> B[Go to Services tab]
B --> C[Add domain service]
C --> D[Add email service]
D --> E[Add SSL service]
E --> F[Add hosting service]
F --> G[Services tracked with expiry dates]
```
**Steps:**
1. Navigate to site detail, Services tab:
- Dev: `http://localhost:9999/platforms/hosting/admin/hosting/sites/{site_id}`
- Prod: `https://hostwizard.lu/admin/hosting/sites/{site_id}`
2. Add a domain service:
- API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{site_id}/services`
- API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites/{site_id}/services`
- Body: `{ "service_type": "domain", "name": "boulangerie-parc.lu domain", "domain_name": "boulangerie-parc.lu", "registrar": "Namecheap", "billing_period": "annual", "price_cents": 1500, "expires_at": "2027-03-01T00:00:00", "auto_renew": true }`
3. Add an email service:
- Body: `{ "service_type": "email", "name": "5 mailboxes", "mailbox_count": 5, "billing_period": "monthly", "price_cents": 999 }`
4. Add an SSL service:
- Body: `{ "service_type": "ssl", "name": "SSL certificate", "billing_period": "annual", "price_cents": 0, "expires_at": "2027-03-01T00:00:00" }`
5. View all services for a site:
- API Dev: `GET http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{site_id}/services`
- API Prod: `GET https://hostwizard.lu/api/admin/hosting/sites/{site_id}/services`
---
### Journey 7: Dashboard & Renewal Monitoring
**Persona:** Platform Admin
**Goal:** Monitor business KPIs and upcoming service renewals
```mermaid
flowchart TD
A[Navigate to Dashboard] --> B[View KPIs]
B --> C[Total sites, live sites, POC sites]
C --> D[Monthly revenue]
D --> E[Active services count]
E --> F[Upcoming renewals in 30 days]
F --> G[Navigate to Client Services]
G --> H[Filter by expiring soon]
H --> I[Renew or update services]
```
**Steps:**
1. Navigate to Dashboard:
- Dev: `http://localhost:9999/platforms/hosting/admin/hosting`
- Prod: `https://hostwizard.lu/admin/hosting`
2. View dashboard stats:
- API Dev: `GET http://localhost:9999/platforms/hosting/api/admin/hosting/stats/dashboard`
- API Prod: `GET https://hostwizard.lu/api/admin/hosting/stats/dashboard`
- Returns: `total_sites`, `live_sites`, `poc_sites`, `sites_by_status`, `active_services`, `monthly_revenue_cents`, `upcoming_renewals`, `services_by_type`
3. Navigate to Client Services for detailed view:
- Dev: `http://localhost:9999/platforms/hosting/admin/hosting/clients`
- Prod: `https://hostwizard.lu/admin/hosting/clients`
4. Filter by type (domain, email, ssl, hosting) or status
5. Toggle "Expiring Soon" to see services expiring within 30 days
---
### Journey 8: Suspend & Reactivate
**Persona:** Platform Admin
**Goal:** Handle suspension (e.g., unpaid invoices) and reactivation
**Steps:**
1. Suspend a site:
- API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/suspend`
- API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites/{id}/suspend`
2. Site status changes to `suspended`
3. Once payment is resolved, reactivate by transitioning back to live:
- The `suspended → live` transition is allowed
4. To permanently close a site:
- API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/cancel`
- API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites/{id}/cancel`
5. `cancelled` is a terminal state — no further transitions allowed
---
### Journey 9: Complete Pipeline (Prospect → Live Site)
**Persona:** Platform Admin
**Goal:** Walk the complete pipeline from prospect to live website
This journey combines the prospecting and hosting modules end-to-end:
```mermaid
flowchart TD
A[Import domain / capture lead] --> B[Enrich & score prospect]
B --> C[Create hosted site from prospect]
C --> D[Build POC website via CMS]
D --> E[Mark POC ready]
E --> F[Send proposal + share preview link]
F --> G{Prospect accepts?}
G -->|Yes| H[Accept → Merchant created]
H --> I[Add client services]
I --> J[Go live with domain]
J --> K[Website live at client domain]
K --> L[Monitor renewals & services]
G -->|No| M[Cancel or follow up later]
```
**Steps:**
1. **Prospecting phase** (see [Prospecting Journeys](prospecting.md)):
- Import domain or capture lead offline
- Run enrichment pipeline
- Score and qualify the prospect
2. **Create hosted site**: `POST /api/admin/hosting/sites/from-prospect/{prospect_id}`
3. **Build POC**: Edit the auto-created Store via CMS
4. **Mark POC ready**: `POST /api/admin/hosting/sites/{id}/mark-poc-ready`
5. **Send proposal**: `POST /api/admin/hosting/sites/{id}/send-proposal`
6. **Share preview**: Send `https://hostwizard.lu/hosting/sites/{id}/preview` to prospect
7. **Accept proposal**: `POST /api/admin/hosting/sites/{id}/accept`
8. **Add services**: `POST /api/admin/hosting/sites/{id}/services` (domain, email, SSL, hosting)
9. **Go live**: `POST /api/admin/hosting/sites/{id}/go-live` with domain
10. **Monitor**: Dashboard at `https://hostwizard.lu/admin/hosting`
---
## Recommended Test Order
1. **Journey 2** - Create a site manually first (simplest path, no prospect dependency)
2. **Journey 3** - Walk the POC → proposal flow
3. **Journey 4** - Accept proposal and verify merchant creation
4. **Journey 5** - Go live with a test domain
5. **Journey 6** - Add client services
6. **Journey 7** - Check dashboard stats
7. **Journey 1** - Test the prospect → hosted site conversion (requires prospecting data)
8. **Journey 8** - Test suspend/reactivate/cancel
9. **Journey 9** - Walk the complete end-to-end pipeline
!!! tip "Test Journey 2 before Journey 1"
Journey 2 (manual creation) doesn't require any prospecting data and is the fastest
way to verify the hosting module works. Journey 1 (from prospect) requires running
the prospecting module first.
This document has moved to the hosting module docs: [User Journeys](../../modules/hosting/user-journeys.md)

View File

@@ -1,794 +1,3 @@
# 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:9999)
The dev server uses path-based platform routing: `http://localhost:9999/platforms/loyalty/...`
### 1. Platform Admin Pages
Login as: `admin@orion.lu` or `samir.boulahtit@gmail.com`
| Page | Dev URL |
|------|---------|
| Programs Dashboard | `http://localhost:9999/platforms/loyalty/admin/loyalty/programs` |
| Analytics | `http://localhost:9999/platforms/loyalty/admin/loyalty/analytics` |
| WizaCorp Detail | `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/1` |
| WizaCorp Settings | `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/1/settings` |
| Fashion Group Detail | `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/2` |
| Fashion Group Settings | `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/2/settings` |
| BookWorld Detail | `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/3` |
| BookWorld Settings | `http://localhost:9999/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:9999/platforms/loyalty/store/ORION/loyalty/terminal` |
| Cards | `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/cards` |
| Settings | `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/settings` |
| Stats | `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/stats` |
| Enroll Customer | `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/enroll` |
**Fashion Group (jane.owner@fashiongroup.com):**
| Page | Dev URL |
|------|---------|
| Terminal | `http://localhost:9999/platforms/loyalty/store/FASHIONHUB/loyalty/terminal` |
| Cards | `http://localhost:9999/platforms/loyalty/store/FASHIONHUB/loyalty/cards` |
| Settings | `http://localhost:9999/platforms/loyalty/store/FASHIONHUB/loyalty/settings` |
| Stats | `http://localhost:9999/platforms/loyalty/store/FASHIONHUB/loyalty/stats` |
| Enroll Customer | `http://localhost:9999/platforms/loyalty/store/FASHIONHUB/loyalty/enroll` |
**BookWorld (bob.owner@bookworld.com):**
| Page | Dev URL |
|------|---------|
| Terminal | `http://localhost:9999/platforms/loyalty/store/BOOKSTORE/loyalty/terminal` |
| Cards | `http://localhost:9999/platforms/loyalty/store/BOOKSTORE/loyalty/cards` |
| Settings | `http://localhost:9999/platforms/loyalty/store/BOOKSTORE/loyalty/settings` |
| Stats | `http://localhost:9999/platforms/loyalty/store/BOOKSTORE/loyalty/stats` |
| Enroll Customer | `http://localhost:9999/platforms/loyalty/store/BOOKSTORE/loyalty/enroll` |
### 3. Customer Storefront Pages
Login as a customer (e.g., `customer1@orion.example.com`).
!!! note "Store domain required"
Storefront pages require a store domain context. Only ORION (`orion.shop`)
and FASHIONHUB (`fashionhub.store`) have domains configured. In dev, storefront
routes may need to be accessed through the store's domain or platform path.
| Page | Dev URL |
|------|---------|
| Loyalty Dashboard | `http://localhost:9999/platforms/loyalty/account/loyalty` |
| Transaction History | `http://localhost:9999/platforms/loyalty/account/loyalty/history` |
### 4. Public Pages (No Auth)
| Page | Dev URL |
|------|---------|
| Self-Enrollment | `http://localhost:9999/platforms/loyalty/loyalty/join` |
| Enrollment Success | `http://localhost:9999/platforms/loyalty/loyalty/join/success` |
### 5. API Endpoints
**Admin API** (prefix: `/platforms/loyalty/api/admin/loyalty/`):
| Method | Dev URL |
|--------|---------|
| GET | `http://localhost:9999/platforms/loyalty/api/admin/loyalty/programs` |
| GET | `http://localhost:9999/platforms/loyalty/api/admin/loyalty/stats` |
**Store API** (prefix: `/platforms/loyalty/api/store/loyalty/`):
| Method | Endpoint | Dev URL |
|--------|----------|---------|
| GET | program | `http://localhost:9999/platforms/loyalty/api/store/loyalty/program` |
| POST | program | `http://localhost:9999/platforms/loyalty/api/store/loyalty/program` |
| POST | stamp | `http://localhost:9999/platforms/loyalty/api/store/loyalty/stamp` |
| POST | points | `http://localhost:9999/platforms/loyalty/api/store/loyalty/points` |
| POST | enroll | `http://localhost:9999/platforms/loyalty/api/store/loyalty/cards/enroll` |
| POST | lookup | `http://localhost:9999/platforms/loyalty/api/store/loyalty/cards/lookup` |
**Storefront API** (prefix: `/platforms/loyalty/api/storefront/`):
| Method | Endpoint | Dev URL |
|--------|----------|---------|
| GET | program | `http://localhost:9999/platforms/loyalty/api/storefront/loyalty/program` |
| POST | enroll | `http://localhost:9999/platforms/loyalty/api/storefront/loyalty/enroll` |
| GET | card | `http://localhost:9999/platforms/loyalty/api/storefront/loyalty/card` |
| GET | transactions | `http://localhost:9999/platforms/loyalty/api/storefront/loyalty/transactions` |
**Public API** (prefix: `/platforms/loyalty/api/loyalty/`):
| Method | Endpoint | Dev URL |
|--------|----------|---------|
| GET | program | `http://localhost:9999/platforms/loyalty/api/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/storefront/loyalty/card` |
| GET transactions | `https://orion.shop/api/storefront/loyalty/transactions` |
| POST enroll | `https://orion.shop/api/storefront/loyalty/enroll` |
| GET program | `https://orion.shop/api/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/store/loyalty/program` |
| POST program | `https://orion.shop/api/store/loyalty/program` |
| POST stamp | `https://orion.shop/api/store/loyalty/stamp` |
| POST points | `https://orion.shop/api/store/loyalty/points` |
| POST enroll | `https://orion.shop/api/store/loyalty/cards/enroll` |
| POST lookup | `https://orion.shop/api/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/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}.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/storefront/loyalty/card` |
| GET transactions | `https://bookstore.rewardflow.lu/api/storefront/loyalty/transactions` |
| POST enroll | `https://bookstore.rewardflow.lu/api/storefront/loyalty/enroll` |
| GET program | `https://bookstore.rewardflow.lu/api/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/store/loyalty/program` |
| POST stamp | `https://bookstore.rewardflow.lu/api/store/loyalty/stamp` |
| POST points | `https://bookstore.rewardflow.lu/api/store/loyalty/points` |
| POST enroll | `https://bookstore.rewardflow.lu/api/store/loyalty/cards/enroll` |
| POST lookup | `https://bookstore.rewardflow.lu/api/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/admin/loyalty/programs` |
| Admin API - Stats | `GET https://rewardflow.lu/api/admin/loyalty/stats` |
| Public API - Program | `GET https://rewardflow.lu/api/loyalty/programs/ORION` |
| Apple Wallet Pass | `GET https://rewardflow.lu/api/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:9999/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: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://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:9999/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:9999/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:9999/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:9999/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:9999/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:9999/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:9999/platforms/loyalty/api/store/loyalty/program`
- Prod: `POST https://{store_domain}/api/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:9999/platforms/loyalty/api/store/loyalty/pins`
- Prod: `POST https://{store_domain}/api/store/loyalty/pins`
9. Verify program is live - check from another store (same merchant):
- Dev: `http://localhost:9999/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:9999/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:9999/platforms/loyalty/api/store/loyalty/cards/lookup`
- Prod: `POST https://{store_domain}/api/store/loyalty/cards/lookup`
3. Enter staff PIN for verification
4. Add stamp:
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/stamp`
- Prod: `POST https://{store_domain}/api/store/loyalty/stamp`
5. If target reached, redeem reward:
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/stamp/redeem`
- Prod: `POST https://{store_domain}/api/store/loyalty/stamp/redeem`
6. View updated card:
- Dev: `http://localhost:9999/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:9999/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:9999/platforms/loyalty/store/ORION/loyalty/terminal`
- Prod: `https://{store_domain}/store/ORION/loyalty/terminal`
2. Lookup card:
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/cards/lookup`
- Prod: `POST https://{store_domain}/api/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:9999/platforms/loyalty/api/store/loyalty/points`
- Prod: `POST https://{store_domain}/api/store/loyalty/points`
5. If enough balance, redeem points for reward:
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/points/redeem`
- Prod: `POST https://{store_domain}/api/store/loyalty/points/redeem`
6. Check store-level stats:
- Dev: `http://localhost:9999/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:9999/platforms/loyalty/loyalty/join`
- Prod (custom domain): `https://orion.shop/loyalty/join`
- Prod (subdomain): `https://bookstore.rewardflow.lu/loyalty/join`
2. Fill in enrollment form (email, name)
3. Submit enrollment:
- Dev: `POST http://localhost:9999/platforms/loyalty/api/storefront/loyalty/enroll`
- Prod (custom domain): `POST https://orion.shop/api/storefront/loyalty/enroll`
- Prod (subdomain): `POST https://bookstore.rewardflow.lu/api/storefront/loyalty/enroll`
4. Redirected to success page:
- Dev: `http://localhost:9999/platforms/loyalty/loyalty/join/success?card=XXXX-XXXX-XXXX`
- Prod (custom domain): `https://orion.shop/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:9999/platforms/loyalty/api/loyalty/passes/apple/{serial_number}.pkpass`
- Prod: `GET https://rewardflow.lu/api/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:9999/platforms/loyalty/account/loyalty`
- Prod (custom domain): `https://orion.shop/account/loyalty`
- Prod (subdomain): `https://bookstore.rewardflow.lu/account/loyalty`
- API Dev: `GET http://localhost:9999/platforms/loyalty/api/storefront/loyalty/card`
- API Prod: `GET https://orion.shop/api/storefront/loyalty/card`
3. View full transaction history:
- Dev: `http://localhost:9999/platforms/loyalty/account/loyalty/history`
- Prod (custom domain): `https://orion.shop/account/loyalty/history`
- Prod (subdomain): `https://bookstore.rewardflow.lu/account/loyalty/history`
- API Dev: `GET http://localhost:9999/platforms/loyalty/api/storefront/loyalty/transactions`
- API Prod: `GET https://orion.shop/api/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:9999/platforms/loyalty/admin/loyalty/programs`
- Prod: `https://rewardflow.lu/admin/loyalty/programs`
3. View platform-wide analytics:
- Dev: `http://localhost:9999/platforms/loyalty/admin/loyalty/analytics`
- Prod: `https://rewardflow.lu/admin/loyalty/analytics`
4. Drill into WizaCorp's program:
- Dev: `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/1`
- Prod: `https://rewardflow.lu/admin/loyalty/merchants/1`
5. Manage WizaCorp's merchant-level settings:
- Dev: `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/1/settings`
- Prod: `https://rewardflow.lu/admin/loyalty/merchants/1/settings`
- API Dev: `PATCH http://localhost:9999/platforms/loyalty/api/admin/loyalty/merchants/1/settings`
- API Prod: `PATCH https://rewardflow.lu/api/admin/loyalty/merchants/1/settings`
6. Adjust settings: PIN policy, self-enrollment toggle, void permissions
7. Check other merchants:
- Dev: `http://localhost:9999/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:9999/platforms/loyalty/store/ORION/loyalty/terminal`
- Prod: `https://{store_domain}/store/ORION/loyalty/terminal`
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/cards/lookup`
- Prod: `POST https://{store_domain}/api/store/loyalty/cards/lookup`
2. View the card's transaction history to find the transaction to void:
- Dev: `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/cards/{card_id}`
- Prod: `https://{store_domain}/store/ORION/loyalty/cards/{card_id}`
- API Dev: `GET http://localhost:9999/platforms/loyalty/api/store/loyalty/cards/{card_id}/transactions`
- API Prod: `GET https://{store_domain}/api/store/loyalty/cards/{card_id}/transactions`
3. Void a stamp transaction:
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/stamp/void`
- Prod: `POST https://{store_domain}/api/store/loyalty/stamp/void`
4. Or void a points transaction:
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/points/void`
- Prod: `POST https://{store_domain}/api/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:9999/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:9999/platforms/loyalty/store/ORION/loyalty/terminal`
- Prod: `https://{store_domain}/store/ORION/loyalty/terminal`
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/stamp`
- Prod: `POST https://{store_domain}/api/store/loyalty/stamp`
2. Customer visits WIZAGADGETS
3. Staff at WIZAGADGETS looks up the same card:
- Dev: `http://localhost:9999/platforms/loyalty/store/WIZAGADGETS/loyalty/terminal`
- Prod: `https://{store_domain}/store/WIZAGADGETS/loyalty/terminal`
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/cards/lookup`
- Prod: `POST https://{store_domain}/api/store/loyalty/cards/lookup`
4. Card is found (same merchant) with accumulated stamps
5. Staff at WIZAGADGETS redeems the reward:
- Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/stamp/redeem`
- Prod: `POST https://{store_domain}/api/store/loyalty/stamp/redeem`
6. Verify transaction history shows both stores:
- Dev: `http://localhost:9999/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.
This document has moved to the loyalty module docs: [User Journeys](../../modules/loyalty/user-journeys.md)

View File

@@ -1,435 +1 @@
# Prospecting Module - User Journeys
## Personas
| # | Persona | Role / Auth | Description |
|---|---------|-------------|-------------|
| 1 | **Platform Admin** | `admin` role | Manages prospects, runs enrichment scans, sends campaigns, exports leads |
!!! note "Admin-only module"
The prospecting module is exclusively for platform admins. There are no store-level
or customer-facing pages. All access requires admin authentication.
---
## Platforms Using Prospecting
The prospecting module is enabled on multiple platforms:
| Platform | Domain | Path Prefix (dev) |
|----------|--------|--------------------|
| HostWizard | hostwizard.lu | `/platforms/hosting/` |
---
## Dev URLs (localhost:9999)
The dev server uses path-based platform routing: `http://localhost:9999/platforms/hosting/...`
### 1. Admin Pages
Login as: `admin@orion.lu` or `samir.boulahtit@gmail.com`
| Page | Dev URL |
|------|---------|
| Dashboard | `http://localhost:9999/platforms/hosting/admin/prospecting` |
| Prospects List | `http://localhost:9999/platforms/hosting/admin/prospecting/prospects` |
| Prospect Detail | `http://localhost:9999/platforms/hosting/admin/prospecting/prospects/{prospect_id}` |
| Leads List | `http://localhost:9999/platforms/hosting/admin/prospecting/leads` |
| Quick Capture | `http://localhost:9999/platforms/hosting/admin/prospecting/capture` |
| Scan Jobs | `http://localhost:9999/platforms/hosting/admin/prospecting/scan-jobs` |
| Campaigns | `http://localhost:9999/platforms/hosting/admin/prospecting/campaigns` |
### 2. Admin API Endpoints
**Prospects** (prefix: `/platforms/hosting/api/admin/prospecting/`):
| Method | Endpoint | Dev URL |
|--------|----------|---------|
| GET | prospects | `http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects` |
| GET | prospect detail | `http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects/{id}` |
| POST | create prospect | `http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects` |
| PUT | update prospect | `http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects/{id}` |
| DELETE | delete prospect | `http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects/{id}` |
| POST | import CSV | `http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects/import` |
**Leads** (prefix: `/platforms/hosting/api/admin/prospecting/`):
| Method | Endpoint | Dev URL |
|--------|----------|---------|
| GET | leads (filtered) | `http://localhost:9999/platforms/hosting/api/admin/prospecting/leads` |
| GET | top priority | `http://localhost:9999/platforms/hosting/api/admin/prospecting/leads/top-priority` |
| GET | quick wins | `http://localhost:9999/platforms/hosting/api/admin/prospecting/leads/quick-wins` |
| GET | export CSV | `http://localhost:9999/platforms/hosting/api/admin/prospecting/leads/export/csv` |
**Enrichment** (prefix: `/platforms/hosting/api/admin/prospecting/`):
| Method | Endpoint | Dev URL |
|--------|----------|---------|
| POST | HTTP check (single) | `http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/http-check/{id}` |
| POST | HTTP check (batch) | `http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/http-check/batch` |
| POST | tech scan (single) | `http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/tech-scan/{id}` |
| POST | tech scan (batch) | `http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/tech-scan/batch` |
| POST | performance (single) | `http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/performance/{id}` |
| POST | performance (batch) | `http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/performance/batch` |
| POST | contacts (single) | `http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/contacts/{id}` |
| POST | full enrichment | `http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/full/{id}` |
| POST | score compute | `http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/score-compute/batch` |
**Interactions** (prefix: `/platforms/hosting/api/admin/prospecting/`):
| Method | Endpoint | Dev URL |
|--------|----------|---------|
| GET | prospect interactions | `http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects/{id}/interactions` |
| POST | log interaction | `http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects/{id}/interactions` |
| GET | upcoming follow-ups | `http://localhost:9999/platforms/hosting/api/admin/prospecting/interactions/upcoming` |
**Campaigns** (prefix: `/platforms/hosting/api/admin/prospecting/`):
| Method | Endpoint | Dev URL |
|--------|----------|---------|
| GET | list templates | `http://localhost:9999/platforms/hosting/api/admin/prospecting/campaigns/templates` |
| POST | create template | `http://localhost:9999/platforms/hosting/api/admin/prospecting/campaigns/templates` |
| PUT | update template | `http://localhost:9999/platforms/hosting/api/admin/prospecting/campaigns/templates/{id}` |
| DELETE | delete template | `http://localhost:9999/platforms/hosting/api/admin/prospecting/campaigns/templates/{id}` |
| POST | preview campaign | `http://localhost:9999/platforms/hosting/api/admin/prospecting/campaigns/preview` |
| POST | send campaign | `http://localhost:9999/platforms/hosting/api/admin/prospecting/campaigns/send` |
| GET | list sends | `http://localhost:9999/platforms/hosting/api/admin/prospecting/campaigns/sends` |
**Stats** (prefix: `/platforms/hosting/api/admin/prospecting/`):
| Method | Endpoint | Dev URL |
|--------|----------|---------|
| GET | dashboard stats | `http://localhost:9999/platforms/hosting/api/admin/prospecting/stats` |
| GET | scan jobs | `http://localhost:9999/platforms/hosting/api/admin/prospecting/stats/jobs` |
---
## Production URLs (hostwizard.lu)
In production, the platform uses **domain-based routing**.
### Admin Pages & API
| Page / Endpoint | Production URL |
|-----------------|----------------|
| Dashboard | `https://hostwizard.lu/admin/prospecting` |
| Prospects | `https://hostwizard.lu/admin/prospecting/prospects` |
| Prospect Detail | `https://hostwizard.lu/admin/prospecting/prospects/{id}` |
| Leads | `https://hostwizard.lu/admin/prospecting/leads` |
| Quick Capture | `https://hostwizard.lu/admin/prospecting/capture` |
| Scan Jobs | `https://hostwizard.lu/admin/prospecting/scan-jobs` |
| Campaigns | `https://hostwizard.lu/admin/prospecting/campaigns` |
| API - Prospects | `GET https://hostwizard.lu/api/admin/prospecting/prospects` |
| API - Leads | `GET https://hostwizard.lu/api/admin/prospecting/leads` |
| API - Stats | `GET https://hostwizard.lu/api/admin/prospecting/stats` |
---
## Data Model
### Prospect
```
Prospect
├── id (PK)
├── channel: DIGITAL | OFFLINE
├── business_name (str)
├── domain_name (str, unique)
├── status: PENDING | ACTIVE | INACTIVE | PARKED | ERROR | CONTACTED | CONVERTED
├── source (str)
├── Digital fields: has_website, uses_https, http_status_code, redirect_url, scan timestamps
├── Offline fields: address, city, postal_code, country, location_lat/lng, captured_by_user_id
├── notes, tags (JSON)
├── created_at, updated_at
└── Relationships: tech_profile, performance_profile, score, contacts, interactions
```
### Prospect Score (0-100)
```
ProspectScore
├── score (0-100, overall)
├── Components: technical_health (max 40), modernity (max 25), business_value (max 25), engagement (max 10)
├── reason_flags (JSON array)
├── score_breakdown (JSON dict)
└── lead_tier: top_priority | quick_win | strategic | low_priority
```
### Status Flow
```
PENDING
↓ (HTTP check determines website status)
ACTIVE (has website) or PARKED (no website / parked domain)
↓ (contact attempt)
CONTACTED
↓ (outcome)
CONVERTED (sale) or INACTIVE (not interested)
Alternative: PENDING → ERROR (invalid domain, technical issues)
```
---
## User Journeys
### Journey 1: Digital Lead Discovery (Domain Scanning)
**Persona:** Platform Admin
**Goal:** Import `.lu` domains, enrich them, and identify sales opportunities
```mermaid
flowchart TD
A[Import CSV of .lu domains] --> B[Prospects created with status PENDING]
B --> C[Run HTTP check batch]
C --> D[Run tech scan batch]
D --> E[Run performance scan batch]
E --> F[Run contact scrape]
F --> G[Compute scores batch]
G --> H[View scored leads]
H --> I{Score tier?}
I -->|>= 70: Top Priority| J[Export & contact immediately]
I -->|50-69: Quick Win| K[Queue for campaign]
I -->|30-49: Strategic| L[Monitor & nurture]
I -->|< 30: Low Priority| M[Deprioritize]
```
**Steps:**
1. Navigate to Dashboard:
- Dev: `http://localhost:9999/platforms/hosting/admin/prospecting`
- Prod: `https://hostwizard.lu/admin/prospecting`
2. Import domains via CSV:
- API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects/import`
- API Prod: `POST https://hostwizard.lu/api/admin/prospecting/prospects/import`
3. Run HTTP batch check:
- API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/http-check/batch`
- API Prod: `POST https://hostwizard.lu/api/admin/prospecting/enrichment/http-check/batch`
4. Run tech scan batch:
- API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/tech-scan/batch`
- API Prod: `POST https://hostwizard.lu/api/admin/prospecting/enrichment/tech-scan/batch`
5. Run performance scan batch:
- API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/performance/batch`
- API Prod: `POST https://hostwizard.lu/api/admin/prospecting/enrichment/performance/batch`
6. Compute scores:
- API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/score-compute/batch`
- API Prod: `POST https://hostwizard.lu/api/admin/prospecting/enrichment/score-compute/batch`
7. Monitor scan jobs:
- Dev: `http://localhost:9999/platforms/hosting/admin/prospecting/scan-jobs`
- Prod: `https://hostwizard.lu/admin/prospecting/scan-jobs`
8. View scored leads:
- Dev: `http://localhost:9999/platforms/hosting/admin/prospecting/leads`
- Prod: `https://hostwizard.lu/admin/prospecting/leads`
9. Export top priority leads:
- API Dev: `GET http://localhost:9999/platforms/hosting/api/admin/prospecting/leads/export/csv?min_score=70`
- API Prod: `GET https://hostwizard.lu/api/admin/prospecting/leads/export/csv?min_score=70`
---
### Journey 2: Offline Lead Capture
**Persona:** Platform Admin (out in the field)
**Goal:** Capture business details from in-person encounters using the mobile-friendly capture form
```mermaid
flowchart TD
A[Meet business owner in-person] --> B[Open Quick Capture on mobile]
B --> C[Enter business name, address, contact info]
C --> D[Prospect created with channel=OFFLINE]
D --> E{Has website?}
E -->|Yes| F[Run full enrichment]
E -->|No| G[Score based on business value only]
F --> H[Prospect fully enriched with score]
G --> H
H --> I[Log interaction: VISIT]
I --> J[Set follow-up date]
```
**Steps:**
1. Open Quick Capture (mobile-friendly):
- Dev: `http://localhost:9999/platforms/hosting/admin/prospecting/capture`
- Prod: `https://hostwizard.lu/admin/prospecting/capture`
2. Fill in business details and submit:
- API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects`
- API Prod: `POST https://hostwizard.lu/api/admin/prospecting/prospects`
- Body includes: `channel: "offline"`, `business_name`, `address`, `city`, `postal_code`
3. Optionally run full enrichment (if domain known):
- API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/full/{id}`
- API Prod: `POST https://hostwizard.lu/api/admin/prospecting/enrichment/full/{id}`
4. Log the interaction:
- API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects/{id}/interactions`
- API Prod: `POST https://hostwizard.lu/api/admin/prospecting/prospects/{id}/interactions`
- Body: `{ "interaction_type": "visit", "notes": "Met at trade fair", "next_action": "Send proposal", "next_action_date": "2026-03-10" }`
---
### Journey 3: Lead Qualification & Export
**Persona:** Platform Admin
**Goal:** Filter enriched prospects by score tier and export qualified leads for outreach
```mermaid
flowchart TD
A[Navigate to Leads page] --> B[Filter by score tier]
B --> C{View preset lists}
C -->|Top Priority| D[Score >= 70]
C -->|Quick Wins| E[Score 50-69]
C -->|Custom filter| F[Set min/max score, channel, contact type]
D --> G[Review leads]
E --> G
F --> G
G --> H[Export as CSV]
H --> I[Use in campaigns or CRM]
```
**Steps:**
1. Navigate to Leads:
- Dev: `http://localhost:9999/platforms/hosting/admin/prospecting/leads`
- Prod: `https://hostwizard.lu/admin/prospecting/leads`
2. View top priority leads:
- API Dev: `GET http://localhost:9999/platforms/hosting/api/admin/prospecting/leads/top-priority`
- API Prod: `GET https://hostwizard.lu/api/admin/prospecting/leads/top-priority`
3. View quick wins:
- API Dev: `GET http://localhost:9999/platforms/hosting/api/admin/prospecting/leads/quick-wins`
- API Prod: `GET https://hostwizard.lu/api/admin/prospecting/leads/quick-wins`
4. Filter with custom parameters:
- API Dev: `GET http://localhost:9999/platforms/hosting/api/admin/prospecting/leads?min_score=60&has_email=true&channel=digital`
- API Prod: `GET https://hostwizard.lu/api/admin/prospecting/leads?min_score=60&has_email=true&channel=digital`
5. Export filtered leads as CSV:
- API Dev: `GET http://localhost:9999/platforms/hosting/api/admin/prospecting/leads/export/csv?min_score=50&lead_tier=quick_win`
- API Prod: `GET https://hostwizard.lu/api/admin/prospecting/leads/export/csv?min_score=50&lead_tier=quick_win`
---
### Journey 4: Campaign Creation & Outreach
**Persona:** Platform Admin
**Goal:** Create email campaign templates and send targeted outreach to qualified prospects
```mermaid
flowchart TD
A[Navigate to Campaigns] --> B[Create campaign template]
B --> C[Choose lead type: no_website, bad_website, etc.]
C --> D[Write email template with variables]
D --> E[Preview rendered for specific prospect]
E --> F{Looks good?}
F -->|Yes| G[Select qualifying leads]
G --> H[Send campaign]
H --> I[Monitor send status]
F -->|No| J[Edit template]
J --> E
```
**Steps:**
1. Navigate to Campaigns:
- Dev: `http://localhost:9999/platforms/hosting/admin/prospecting/campaigns`
- Prod: `https://hostwizard.lu/admin/prospecting/campaigns`
2. Create a campaign template:
- API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/campaigns/templates`
- API Prod: `POST https://hostwizard.lu/api/admin/prospecting/campaigns/templates`
- Body: `{ "name": "No Website Outreach", "lead_type": "no_website", "channel": "email", "language": "fr", "subject_template": "Votre presence en ligne", "body_template": "Bonjour {{business_name}}..." }`
3. Preview the template for a specific prospect:
- API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/campaigns/preview`
- API Prod: `POST https://hostwizard.lu/api/admin/prospecting/campaigns/preview`
- Body: `{ "template_id": 1, "prospect_id": 42 }`
4. Send campaign to selected prospects:
- API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/campaigns/send`
- API Prod: `POST https://hostwizard.lu/api/admin/prospecting/campaigns/send`
- Body: `{ "template_id": 1, "prospect_ids": [42, 43, 44] }`
5. Monitor campaign sends:
- API Dev: `GET http://localhost:9999/platforms/hosting/api/admin/prospecting/campaigns/sends`
- API Prod: `GET https://hostwizard.lu/api/admin/prospecting/campaigns/sends`
---
### Journey 5: Interaction Tracking & Follow-ups
**Persona:** Platform Admin
**Goal:** Log interactions with prospects and track follow-up actions
```mermaid
flowchart TD
A[Open prospect detail] --> B[View interaction history]
B --> C[Log new interaction]
C --> D[Set next action & date]
D --> E[View upcoming follow-ups]
E --> F[Complete follow-up]
F --> G{Positive outcome?}
G -->|Yes| H[Mark as CONTACTED → CONVERTED]
G -->|No| I[Schedule next follow-up or mark INACTIVE]
```
**Steps:**
1. View prospect detail:
- Dev: `http://localhost:9999/platforms/hosting/admin/prospecting/prospects/{id}`
- Prod: `https://hostwizard.lu/admin/prospecting/prospects/{id}`
2. View interactions:
- API Dev: `GET http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects/{id}/interactions`
- API Prod: `GET https://hostwizard.lu/api/admin/prospecting/prospects/{id}/interactions`
3. Log a new interaction:
- API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/prospects/{id}/interactions`
- API Prod: `POST https://hostwizard.lu/api/admin/prospecting/prospects/{id}/interactions`
- Body: `{ "interaction_type": "call", "subject": "Follow-up call", "outcome": "positive", "next_action": "Send proposal", "next_action_date": "2026-03-15" }`
4. View upcoming follow-ups across all prospects:
- API Dev: `GET http://localhost:9999/platforms/hosting/api/admin/prospecting/interactions/upcoming`
- API Prod: `GET https://hostwizard.lu/api/admin/prospecting/interactions/upcoming`
---
### Journey 6: Full Enrichment Pipeline (Single Prospect)
**Persona:** Platform Admin
**Goal:** Run the complete enrichment pipeline for a single prospect to get all data at once
```mermaid
flowchart TD
A[Open prospect detail] --> B[Click 'Full Enrichment']
B --> C[Step 1: HTTP check]
C --> D{Has website?}
D -->|Yes| E[Step 2: Tech scan]
D -->|No| H[Step 5: Compute score]
E --> F[Step 3: Performance audit]
F --> G[Step 4: Contact scrape]
G --> H
H --> I[Prospect fully enriched]
I --> J[View score & breakdown]
```
**Steps:**
1. Run full enrichment for a prospect:
- API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/prospecting/enrichment/full/{prospect_id}`
- API Prod: `POST https://hostwizard.lu/api/admin/prospecting/enrichment/full/{prospect_id}`
2. View the enriched prospect detail:
- Dev: `http://localhost:9999/platforms/hosting/admin/prospecting/prospects/{prospect_id}`
- Prod: `https://hostwizard.lu/admin/prospecting/prospects/{prospect_id}`
The full enrichment runs 5 sequential steps:
1. **HTTP check** — Verifies domain connectivity, checks HTTPS, records redirects
2. **Tech scan** — Detects CMS, server, hosting provider, JS framework, SSL cert details
3. **Performance audit** — Runs PageSpeed analysis, records load times and scores
4. **Contact scrape** — Extracts emails, phones, addresses, social links from the website
5. **Score compute** — Calculates 0-100 opportunity score with component breakdown
---
## Recommended Test Order
1. **Journey 1** (steps 1-3) - Import domains and run HTTP checks first
2. **Journey 6** - Run full enrichment on a single prospect to test the complete pipeline
3. **Journey 1** (steps 4-9) - Run batch scans and view scored leads
4. **Journey 2** - Test offline capture on mobile
5. **Journey 3** - Filter and export leads
6. **Journey 4** - Create campaign templates and send to prospects
7. **Journey 5** - Log interactions and check follow-ups
!!! tip "Enrichment order matters"
The enrichment pipeline must run in order: HTTP check first (determines if website exists),
then tech scan, performance, and contacts (all require a live website). Score computation
should run last as it uses data from all other steps.
This document has moved to the prospecting module docs: [User Journeys](../../modules/prospecting/user-journeys.md)