",
+ "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 "
+
+# 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!
diff --git a/app/modules/cms/docs/index.md b/app/modules/cms/docs/index.md
new file mode 100644
index 00000000..f09a4bb5
--- /dev/null
+++ b/app/modules/cms/docs/index.md
@@ -0,0 +1,61 @@
+# Content Management
+
+Content pages, media library, and store themes.
+
+## Overview
+
+| Aspect | Detail |
+|--------|--------|
+| Code | `cms` |
+| Classification | Core |
+| Dependencies | None |
+| Status | Active |
+
+## Features
+
+- `cms_basic` — Basic content page management
+- `cms_custom_pages` — Custom page creation
+- `cms_unlimited_pages` — Unlimited pages (tier-gated)
+- `cms_templates` — Page templates
+- `cms_seo` — SEO metadata management
+- `media_library` — Media file upload and management
+
+## Permissions
+
+| Permission | Description |
+|------------|-------------|
+| `cms.view_pages` | View content pages |
+| `cms.manage_pages` | Create/edit/delete pages |
+| `cms.view_media` | View media library |
+| `cms.manage_media` | Upload/delete media files |
+| `cms.manage_themes` | Manage store themes |
+
+## Data Model
+
+See [Data Model](data-model.md) for full entity relationships and schema.
+
+- **ContentPage** — Multi-language content pages with platform/store hierarchy
+- **MediaFile** — Media files with optimization and folder organization
+- **StoreTheme** — Theme presets, colors, fonts, and branding
+
+## API Endpoints
+
+| Method | Path | Description |
+|--------|------|-------------|
+| `*` | `/api/v1/admin/content-pages/*` | Content page CRUD |
+| `*` | `/api/v1/admin/media/*` | Media library management |
+| `*` | `/api/v1/admin/images/*` | Image upload/management |
+| `*` | `/api/v1/admin/store-themes/*` | Theme management |
+
+## Configuration
+
+No module-specific configuration.
+
+## Additional Documentation
+
+- [Data Model](data-model.md) — Entity relationships and database schema
+- [Architecture](architecture.md) — CMS architecture and database schema
+- [Implementation](implementation.md) — Implementation checklist and status
+- [Email Templates](email-templates.md) — Email template system architecture
+- [Email Templates Guide](email-templates-guide.md) — Template customization guide
+- [Media Library](media-library.md) — Media library usage guide
diff --git a/app/modules/cms/docs/media-library.md b/app/modules/cms/docs/media-library.md
new file mode 100644
index 00000000..48d3d06e
--- /dev/null
+++ b/app/modules/cms/docs/media-library.md
@@ -0,0 +1,182 @@
+# Media Library
+
+The media library provides centralized management of uploaded files (images, documents) for stores. Each store has their own isolated media storage.
+
+## Overview
+
+- **Storage Location**: `uploads/stores/{store_id}/{folder}/`
+- **Supported Types**: Images (JPG, PNG, GIF, WebP), Documents (PDF)
+- **Max File Size**: 10MB per file
+- **Automatic Thumbnails**: Generated for images (200x200px)
+
+## API Endpoints
+
+### Admin Media Management
+
+Admins can manage media for any store:
+
+```
+GET /api/v1/admin/media/stores/{store_id} # List store's media
+POST /api/v1/admin/media/stores/{store_id}/upload # Upload file
+GET /api/v1/admin/media/stores/{store_id}/{id} # Get media details
+DELETE /api/v1/admin/media/stores/{store_id}/{id} # Delete media
+```
+
+### Query Parameters
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `skip` | int | Pagination offset (default: 0) |
+| `limit` | int | Items per page (default: 100, max: 1000) |
+| `media_type` | string | Filter by type: `image`, `video`, `document` |
+| `folder` | string | Filter by folder: `products`, `general`, etc. |
+| `search` | string | Search by filename |
+
+### Upload Response
+
+```json
+{
+ "success": true,
+ "message": "File uploaded successfully",
+ "media": {
+ "id": 1,
+ "filename": "product-image.jpg",
+ "file_url": "/uploads/stores/1/products/abc123.jpg",
+ "url": "/uploads/stores/1/products/abc123.jpg",
+ "thumbnail_url": "/uploads/stores/1/thumbnails/thumb_abc123.jpg",
+ "media_type": "image",
+ "file_size": 245760,
+ "width": 1200,
+ "height": 800
+ }
+}
+```
+
+## Media Picker Component
+
+A reusable Alpine.js component for selecting images from the media library.
+
+### Usage in Templates
+
+```jinja2
+{% from 'shared/macros/modals.html' import media_picker_modal %}
+
+{# Single image selection #}
+{{ media_picker_modal(
+ id='media-picker-main',
+ show_var='showMediaPicker',
+ store_id_var='storeId',
+ title='Select Image'
+) }}
+
+{# Multiple image selection #}
+{{ media_picker_modal(
+ id='media-picker-additional',
+ show_var='showMediaPickerAdditional',
+ store_id_var='storeId',
+ multi_select=true,
+ title='Select Additional Images'
+) }}
+```
+
+### JavaScript Integration
+
+Include the media picker mixin in your Alpine.js component:
+
+```javascript
+function myComponent() {
+ return {
+ ...data(),
+
+ // Include media picker functionality
+ ...mediaPickerMixin(() => this.storeId, false),
+
+ storeId: null,
+
+ // Override to handle selected image
+ setMainImage(media) {
+ this.form.image_url = media.url;
+ },
+
+ // Override for multiple images
+ addAdditionalImages(mediaList) {
+ const urls = mediaList.map(m => m.url);
+ this.form.additional_images.push(...urls);
+ }
+ };
+}
+```
+
+### Media Picker Mixin API
+
+| Property/Method | Description |
+|-----------------|-------------|
+| `showMediaPicker` | Boolean to show/hide main image picker modal |
+| `showMediaPickerAdditional` | Boolean to show/hide additional images picker |
+| `mediaPickerState` | Object containing loading, media array, selected items |
+| `openMediaPickerMain()` | Open picker for main image |
+| `openMediaPickerAdditional()` | Open picker for additional images |
+| `loadMediaLibrary()` | Fetch media from API |
+| `uploadMediaFile(event)` | Handle file upload |
+| `toggleMediaSelection(media)` | Select/deselect a media item |
+| `confirmMediaSelection()` | Confirm selection and call callbacks |
+| `setMainImage(media)` | Override to handle main image selection |
+| `addAdditionalImages(mediaList)` | Override to handle multiple selections |
+
+## File Storage
+
+### Directory Structure
+
+```
+uploads/
+└── stores/
+ └── {store_id}/
+ ├── products/ # Product images
+ ├── general/ # General uploads
+ └── thumbnails/ # Auto-generated thumbnails
+```
+
+### URL Paths
+
+Files are served from `/uploads/` path:
+- Full image: `/uploads/stores/1/products/image.jpg`
+- Thumbnail: `/uploads/stores/1/thumbnails/thumb_image.jpg`
+
+## Database Model
+
+```python
+class MediaFile(Base):
+ id: int
+ store_id: int
+ filename: str # Stored filename (UUID-based)
+ original_filename: str # Original upload name
+ file_path: str # Relative path from uploads/
+ thumbnail_path: str # Thumbnail relative path
+ media_type: str # image, video, document
+ mime_type: str # image/jpeg, etc.
+ file_size: int # Bytes
+ width: int # Image width
+ height: int # Image height
+ folder: str # products, general, etc.
+```
+
+## Product Images
+
+Products support both a main image and additional images:
+
+```python
+class Product(Base):
+ primary_image_url: str # Main product image
+ additional_images: list[str] # Array of additional image URLs
+```
+
+### In Product Forms
+
+The product create/edit forms include:
+1. **Main Image**: Single image with preview and media picker
+2. **Additional Images**: Grid of images with add/remove functionality
+
+Both support:
+- Browsing the store's media library
+- Uploading new images directly
+- Entering external URLs manually
diff --git a/app/modules/contracts/docs/index.md b/app/modules/contracts/docs/index.md
new file mode 100644
index 00000000..0ab222ef
--- /dev/null
+++ b/app/modules/contracts/docs/index.md
@@ -0,0 +1,33 @@
+# Module Contracts
+
+Cross-module contracts using Protocol pattern for type-safe inter-module communication.
+
+## Overview
+
+| Aspect | Detail |
+|--------|--------|
+| Code | `contracts` |
+| Classification | Core |
+| Dependencies | None |
+| Status | Active |
+
+## Features
+
+- `service_protocols` — Protocol definitions for cross-module service interfaces
+- `cross_module_interfaces` — Type-safe inter-module communication contracts
+
+## Permissions
+
+No permissions defined.
+
+## Data Model
+
+No database models.
+
+## API Endpoints
+
+No API endpoints.
+
+## Configuration
+
+No module-specific configuration.
diff --git a/app/modules/core/docs/index.md b/app/modules/core/docs/index.md
new file mode 100644
index 00000000..abb9abc7
--- /dev/null
+++ b/app/modules/core/docs/index.md
@@ -0,0 +1,44 @@
+# Core Platform
+
+Dashboard, settings, and profile management. Required for basic operation.
+
+## Overview
+
+| Aspect | Detail |
+|--------|--------|
+| Code | `core` |
+| Classification | Core |
+| Dependencies | None |
+| Status | Active |
+
+## Features
+
+- `dashboard` — Main admin dashboard
+- `settings` — Platform and store settings management
+- `profile` — User profile management
+
+## Permissions
+
+| Permission | Description |
+|------------|-------------|
+| `dashboard.view` | View the admin dashboard |
+| `settings.view` | View settings |
+| `settings.edit` | Edit settings |
+| `settings.theme` | Manage theme settings |
+| `settings.domains` | Manage domains (owner only) |
+
+## Data Model
+
+- **AdminMenuConfig** — Stores menu configuration for admin sidebar
+
+## API Endpoints
+
+| Method | Path | Description |
+|--------|------|-------------|
+| `GET` | `/api/v1/admin/dashboard` | Dashboard data |
+| `GET/PATCH` | `/api/v1/admin/settings` | Settings CRUD |
+| `GET/PATCH` | `/api/v1/admin/menu-config` | Menu configuration |
+
+## Configuration
+
+No module-specific configuration.
diff --git a/app/modules/customers/docs/index.md b/app/modules/customers/docs/index.md
new file mode 100644
index 00000000..ca6208d6
--- /dev/null
+++ b/app/modules/customers/docs/index.md
@@ -0,0 +1,47 @@
+# Customer Management
+
+Customer database, profiles, addresses, and segmentation.
+
+## Overview
+
+| Aspect | Detail |
+|--------|--------|
+| Code | `customers` |
+| Classification | Core |
+| Dependencies | None |
+| Status | Active |
+
+## Features
+
+- `customer_view` — View customer records
+- `customer_export` — Export customer data
+- `customer_profiles` — Customer profile management
+- `customer_segmentation` — Customer segmentation and filtering
+- `customer_addresses` — Address book management
+- `customer_authentication` — Storefront customer login/registration
+
+## Permissions
+
+| Permission | Description |
+|------------|-------------|
+| `customers.view` | View customer records |
+| `customers.edit` | Edit customer data |
+| `customers.delete` | Delete customers |
+| `customers.export` | Export customer data |
+
+## Data Model
+
+- **Customer** — Customer records with store-scoped profiles
+- **PasswordResetToken** — Password reset tokens for customer accounts
+
+## API Endpoints
+
+| Method | Path | Description |
+|--------|------|-------------|
+| `*` | `/api/v1/admin/customers/*` | Admin customer management |
+| `*` | `/api/v1/store/customers/*` | Store-level customer management |
+| `*` | `/api/v1/storefront/customers/*` | Customer self-service (profile, addresses) |
+
+## Configuration
+
+No module-specific configuration.
diff --git a/app/modules/dev_tools/docs/index.md b/app/modules/dev_tools/docs/index.md
new file mode 100644
index 00000000..9f513af6
--- /dev/null
+++ b/app/modules/dev_tools/docs/index.md
@@ -0,0 +1,42 @@
+# Developer Tools
+
+Internal development tools including code quality scanning, test execution, component library, and icon browser.
+
+## Overview
+
+| Aspect | Detail |
+|--------|--------|
+| Code | `dev-tools` |
+| Classification | Internal |
+| Dependencies | None |
+| Status | Active |
+
+## Features
+
+- `component_library` — UI component browser and documentation
+- `icon_browser` — Icon set browser
+- `code_quality` — Code quality scanning and reporting
+- `architecture_validation` — Module architecture validation
+- `security_validation` — Security rule checking
+- `performance_validation` — Performance rule checking
+- `test_runner` — Test execution interface
+- `violation_management` — Architecture violation tracking
+
+## Permissions
+
+No permissions defined (internal module, admin-only access).
+
+## Data Model
+
+- **ArchitectureScan** — Code quality scan results
+- **TestRun** — Test execution records
+
+## API Endpoints
+
+| Method | Path | Description |
+|--------|------|-------------|
+| `*` | `/api/v1/admin/dev-tools/*` | Dev tools API |
+
+## Configuration
+
+No module-specific configuration.
diff --git a/app/modules/hosting/docs/index.md b/app/modules/hosting/docs/index.md
new file mode 100644
index 00000000..e7ea8f51
--- /dev/null
+++ b/app/modules/hosting/docs/index.md
@@ -0,0 +1,49 @@
+# Hosting
+
+Web hosting, domains, email, and website building for Luxembourg businesses.
+
+## Overview
+
+| Aspect | Detail |
+|--------|--------|
+| Code | `hosting` |
+| Classification | Optional |
+| Dependencies | `prospecting` |
+| Status | Active |
+
+## Features
+
+- `hosting` — Web hosting management
+- `domains` — Domain registration and management
+- `email` — Email hosting
+- `ssl` — SSL certificate management
+- `poc_sites` — Proof-of-concept site builder
+
+## Permissions
+
+| Permission | Description |
+|------------|-------------|
+| `hosting.view` | View hosting data |
+| `hosting.manage` | Manage hosting services |
+
+## Data Model
+
+- **HostedSite** — Hosted website records
+- **ClientService** — Client service subscriptions
+
+## API Endpoints
+
+| Method | Path | Description |
+|--------|------|-------------|
+| `*` | `/api/v1/admin/hosting/*` | Admin hosting management |
+| `*` | `/api/v1/admin/hosting/services/*` | Service management |
+| `*` | `/api/v1/admin/hosting/sites/*` | Site management |
+| `GET` | `/api/v1/admin/hosting/stats/*` | Hosting statistics |
+
+## Configuration
+
+No module-specific configuration.
+
+## Additional Documentation
+
+- [User Journeys](user-journeys.md) — Hosting lifecycle user journeys
diff --git a/app/modules/hosting/docs/user-journeys.md b/app/modules/hosting/docs/user-journeys.md
new file mode 100644
index 00000000..de19c42e
--- /dev/null
+++ b/app/modules/hosting/docs/user-journeys.md
@@ -0,0 +1,502 @@
+# 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/user-journeys.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/user-journeys.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.
diff --git a/app/modules/inventory/docs/data-model.md b/app/modules/inventory/docs/data-model.md
new file mode 100644
index 00000000..b6b4368b
--- /dev/null
+++ b/app/modules/inventory/docs/data-model.md
@@ -0,0 +1,82 @@
+# Inventory Data Model
+
+Entity relationships and database schema for the inventory module.
+
+## Entity Relationship Overview
+
+```
+Store 1──* Inventory *──1 Product
+ │
+ └──* InventoryTransaction
+ │
+ └──? Order (for reserve/fulfill/release)
+```
+
+## Models
+
+### Inventory
+
+Stock quantities at warehouse bin locations. Supports multi-location inventory with reservation tracking.
+
+| Field | Type | Constraints | Description |
+|-------|------|-------------|-------------|
+| `id` | Integer | PK | Primary key |
+| `product_id` | Integer | FK, not null, indexed | Product reference |
+| `store_id` | Integer | FK, not null, indexed | Store reference |
+| `warehouse` | String | not null, default "strassen", indexed | Warehouse identifier |
+| `bin_location` | String | not null, indexed | Bin code (e.g., "SA-10-02") |
+| `quantity` | Integer | not null, default 0 | Total quantity at bin |
+| `reserved_quantity` | Integer | default 0 | Reserved/allocated quantity |
+| `gtin` | String | indexed | GTIN reference (duplicated for reporting) |
+| `created_at` | DateTime | tz-aware | Record creation time |
+| `updated_at` | DateTime | tz-aware | Record update time |
+
+**Unique Constraint**: `(product_id, warehouse, bin_location)`
+**Composite Indexes**: `(store_id, product_id)`, `(warehouse, bin_location)`
+
+**Key Property**: `available_quantity` = max(0, quantity - reserved_quantity)
+
+### InventoryTransaction
+
+Complete audit trail for all stock movements with before/after snapshots.
+
+| Field | Type | Constraints | Description |
+|-------|------|-------------|-------------|
+| `id` | Integer | PK | Primary key |
+| `store_id` | Integer | FK, not null, indexed | Store reference |
+| `product_id` | Integer | FK, not null, indexed | Product reference |
+| `inventory_id` | Integer | FK, nullable, indexed | Inventory record reference |
+| `transaction_type` | Enum | not null, indexed | Type of stock movement |
+| `quantity_change` | Integer | not null | Change amount (+ add, - remove) |
+| `quantity_after` | Integer | not null | Quantity snapshot after transaction |
+| `reserved_after` | Integer | not null, default 0 | Reserved quantity snapshot |
+| `location` | String | nullable | Location context |
+| `warehouse` | String | nullable | Warehouse context |
+| `order_id` | Integer | FK, nullable, indexed | Related order |
+| `order_number` | String | nullable | Order number for display |
+| `reason` | Text | nullable | Human-readable reason |
+| `created_by` | String | nullable | User/system identifier |
+| `created_at` | DateTime | not null, indexed | Timestamp (UTC) |
+
+**Composite Indexes**: `(store_id, product_id)`, `(store_id, created_at)`, `(transaction_type, created_at)`
+
+## Enums
+
+### TransactionType
+
+| Value | Description |
+|-------|-------------|
+| `reserve` | Stock reserved for order |
+| `fulfill` | Reserved stock consumed (shipped) |
+| `release` | Reserved stock released (cancelled) |
+| `adjust` | Manual adjustment (+/-) |
+| `set` | Set to exact quantity |
+| `import` | Initial import/sync |
+| `return` | Stock returned from customer |
+
+## Design Patterns
+
+- **Multi-location**: Inventory tracked per warehouse + bin location
+- **Reservation system**: Separate quantity and reserved_quantity for order holds
+- **Full audit trail**: Every stock change recorded with before/after snapshots
+- **Order integration**: Transactions linked to orders for reserve/fulfill/release cycle
diff --git a/app/modules/inventory/docs/index.md b/app/modules/inventory/docs/index.md
new file mode 100644
index 00000000..06882699
--- /dev/null
+++ b/app/modules/inventory/docs/index.md
@@ -0,0 +1,53 @@
+# Inventory Management
+
+Stock level tracking, inventory locations, low stock alerts, transaction history, and bulk imports.
+
+## Overview
+
+| Aspect | Detail |
+|--------|--------|
+| Code | `inventory` |
+| Classification | Optional |
+| Dependencies | `catalog`, `orders` |
+| Status | Active |
+
+## Features
+
+- `inventory_basic` — Basic stock tracking
+- `inventory_locations` — Multi-location inventory
+- `low_stock_alerts` — Low stock notifications
+- `inventory_purchase_orders` — Purchase order management
+- `product_management` — Product inventory management
+- `inventory_transactions` — Stock movement audit trail
+- `inventory_import` — Bulk stock import
+
+## Permissions
+
+| Permission | Description |
+|------------|-------------|
+| `stock.view` | View inventory data |
+| `stock.edit` | Edit stock levels |
+| `stock.transfer` | Transfer stock between locations |
+
+## Data Model
+
+See [Data Model](data-model.md) for full entity relationships and schema.
+
+- **Inventory** — Stock quantities at warehouse bin locations
+- **InventoryTransaction** — Complete audit trail for stock movements
+
+## API Endpoints
+
+| Method | Path | Description |
+|--------|------|-------------|
+| `*` | `/api/v1/admin/inventory/*` | Admin inventory management |
+| `*` | `/api/v1/store/inventory/*` | Store inventory management |
+
+## Configuration
+
+No module-specific configuration.
+
+## Additional Documentation
+
+- [Data Model](data-model.md) — Entity relationships and database schema
+- [User Guide](user-guide.md) — Inventory management guide with API reference
diff --git a/app/modules/inventory/docs/user-guide.md b/app/modules/inventory/docs/user-guide.md
new file mode 100644
index 00000000..2836f99d
--- /dev/null
+++ b/app/modules/inventory/docs/user-guide.md
@@ -0,0 +1,366 @@
+# Inventory Management
+
+## Overview
+
+The Orion platform provides comprehensive inventory management with support for:
+
+- **Multi-location tracking** - Track stock across warehouses, stores, and storage bins
+- **Reservation system** - Reserve items for pending orders
+- **Digital products** - Automatic unlimited inventory for digital goods
+- **Admin operations** - Manage inventory on behalf of stores
+
+---
+
+## Key Concepts
+
+### Storage Locations
+
+Inventory is tracked at the **storage location level**. Each product can have stock in multiple locations:
+
+```
+Product: "Wireless Headphones"
+├── WAREHOUSE_MAIN: 100 units (10 reserved)
+├── WAREHOUSE_WEST: 50 units (0 reserved)
+└── STORE_FRONT: 25 units (5 reserved)
+
+Total: 175 units | Reserved: 15 | Available: 160
+```
+
+**Location naming:** Locations are text strings, normalized to UPPERCASE (e.g., `WAREHOUSE_A`, `STORE_01`).
+
+### Inventory States
+
+| Field | Description |
+|-------|-------------|
+| `quantity` | Total physical stock at location |
+| `reserved_quantity` | Items reserved for pending orders |
+| `available_quantity` | `quantity - reserved_quantity` (can be sold) |
+
+### Product Types & Inventory
+
+| Product Type | Inventory Behavior |
+|--------------|-------------------|
+| **Physical** | Requires inventory tracking, orders check available stock |
+| **Digital** | **Unlimited inventory** - no stock constraints |
+| **Service** | Treated as digital (unlimited) |
+| **Subscription** | Treated as digital (unlimited) |
+
+---
+
+## Digital Products
+
+Digital products have **unlimited inventory** by default. This means:
+
+- Orders for digital products never fail due to "insufficient inventory"
+- No need to create inventory entries for digital products
+- The `available_inventory` property returns `999999` (effectively unlimited)
+
+### How It Works
+
+```python
+# In Product model
+@property
+def has_unlimited_inventory(self) -> bool:
+ """Digital products have unlimited inventory."""
+ return self.is_digital
+
+@property
+def available_inventory(self) -> int:
+ """Calculate available inventory."""
+ if self.has_unlimited_inventory:
+ return 999999 # Unlimited
+ return sum(inv.available_quantity for inv in self.inventory_entries)
+```
+
+### Setting a Product as Digital
+
+Digital products are identified by the `is_digital` flag on the `MarketplaceProduct`:
+
+```python
+marketplace_product.is_digital = True
+marketplace_product.product_type_enum = "digital"
+marketplace_product.digital_delivery_method = "license_key" # or "download", "email"
+```
+
+---
+
+## Inventory Operations
+
+### Set Inventory
+
+Replace the exact quantity at a location:
+
+```http
+POST /api/v1/store/inventory/set
+{
+ "product_id": 123,
+ "location": "WAREHOUSE_A",
+ "quantity": 100
+}
+```
+
+### Adjust Inventory
+
+Add or remove stock (positive = add, negative = remove):
+
+```http
+POST /api/v1/store/inventory/adjust
+{
+ "product_id": 123,
+ "location": "WAREHOUSE_A",
+ "quantity": -10 // Remove 10 units
+}
+```
+
+### Reserve Inventory
+
+Mark items as reserved for an order:
+
+```http
+POST /api/v1/store/inventory/reserve
+{
+ "product_id": 123,
+ "location": "WAREHOUSE_A",
+ "quantity": 5
+}
+```
+
+### Release Reservation
+
+Cancel a reservation (order cancelled):
+
+```http
+POST /api/v1/store/inventory/release
+{
+ "product_id": 123,
+ "location": "WAREHOUSE_A",
+ "quantity": 5
+}
+```
+
+### Fulfill Reservation
+
+Complete an order (items shipped):
+
+```http
+POST /api/v1/store/inventory/fulfill
+{
+ "product_id": 123,
+ "location": "WAREHOUSE_A",
+ "quantity": 5
+}
+```
+
+This decreases both `quantity` and `reserved_quantity`.
+
+---
+
+## Reservation Workflow
+
+```
+┌─────────────────┐
+│ Order Created │
+└────────┬────────┘
+ │
+ ▼
+┌─────────────────┐
+│ Reserve Items │ reserved_quantity += order_qty
+└────────┬────────┘
+ │
+ ┌────┴────┐
+ │ │
+ ▼ ▼
+┌───────┐ ┌──────────┐
+│Cancel │ │ Ship │
+└───┬───┘ └────┬─────┘
+ │ │
+ ▼ ▼
+┌─────────┐ ┌──────────────┐
+│ Release │ │ Fulfill │
+│reserved │ │ quantity -= │
+│ -= qty │ │ reserved -= │
+└─────────┘ └──────────────┘
+```
+
+---
+
+## Admin Inventory Management
+
+Administrators can manage inventory on behalf of any store through the admin UI at `/admin/inventory` or via the API.
+
+### Admin UI Features
+
+The admin inventory page provides:
+
+- **Overview Statistics** - Total entries, stock quantities, reserved items, and low stock alerts
+- **Filtering** - Filter by store, location, and low stock threshold
+- **Search** - Search by product title or SKU
+- **Stock Adjustment** - Add or remove stock with optional reason tracking
+- **Set Quantity** - Set exact stock quantity at any location
+- **Delete Entries** - Remove inventory entries
+
+### Admin API Endpoints
+
+### List All Inventory
+
+```http
+GET /api/v1/admin/inventory
+GET /api/v1/admin/inventory?store_id=1
+GET /api/v1/admin/inventory?low_stock=10
+```
+
+### Get Inventory Statistics
+
+```http
+GET /api/v1/admin/inventory/stats
+
+Response:
+{
+ "total_entries": 150,
+ "total_quantity": 5000,
+ "total_reserved": 200,
+ "total_available": 4800,
+ "low_stock_count": 12,
+ "stores_with_inventory": 5,
+ "unique_locations": 8
+}
+```
+
+### Low Stock Alerts
+
+```http
+GET /api/v1/admin/inventory/low-stock?threshold=10
+
+Response:
+[
+ {
+ "product_id": 123,
+ "store_name": "TechStore",
+ "product_title": "USB Cable",
+ "location": "WAREHOUSE_A",
+ "quantity": 3,
+ "available_quantity": 2
+ }
+]
+```
+
+### Set Inventory (Admin)
+
+```http
+POST /api/v1/admin/inventory/set
+{
+ "store_id": 1,
+ "product_id": 123,
+ "location": "WAREHOUSE_A",
+ "quantity": 100
+}
+```
+
+### Adjust Inventory (Admin)
+
+```http
+POST /api/v1/admin/inventory/adjust
+{
+ "store_id": 1,
+ "product_id": 123,
+ "location": "WAREHOUSE_A",
+ "quantity": 25,
+ "reason": "Restocking from supplier"
+}
+```
+
+---
+
+## Database Schema
+
+### Inventory Table
+
+```sql
+CREATE TABLE inventory (
+ id SERIAL PRIMARY KEY,
+ product_id INTEGER NOT NULL REFERENCES products(id),
+ store_id INTEGER NOT NULL REFERENCES stores(id),
+ location VARCHAR NOT NULL,
+ quantity INTEGER NOT NULL DEFAULT 0,
+ reserved_quantity INTEGER DEFAULT 0,
+ gtin VARCHAR,
+ created_at TIMESTAMP DEFAULT NOW(),
+ updated_at TIMESTAMP DEFAULT NOW(),
+
+ UNIQUE(product_id, location)
+);
+
+CREATE INDEX idx_inventory_store_product ON inventory(store_id, product_id);
+CREATE INDEX idx_inventory_product_location ON inventory(product_id, location);
+```
+
+### Constraints
+
+- **Unique constraint:** `(product_id, location)` - One entry per product/location
+- **Foreign keys:** References `products` and `stores` tables
+- **Non-negative:** `quantity` and `reserved_quantity` must be >= 0
+
+---
+
+## Best Practices
+
+### Physical Products
+
+1. **Create inventory entries** before accepting orders
+2. **Use meaningful location names** (e.g., `WAREHOUSE_MAIN`, `STORE_NYC`)
+3. **Monitor low stock** using the admin dashboard or API
+4. **Reserve on order creation** to prevent overselling
+
+### Digital Products
+
+1. **No inventory setup needed** - unlimited by default
+2. **Optional:** Create entries for license key tracking
+3. **Focus on fulfillment** - digital delivery mechanism
+
+### Multi-Location
+
+1. **Aggregate queries** use `Product.total_inventory` and `Product.available_inventory`
+2. **Location-specific** operations use the Inventory model directly
+3. **Transfers** between locations: adjust down at source, adjust up at destination
+
+---
+
+## API Reference
+
+### Store Endpoints
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| POST | `/api/v1/store/inventory/set` | Set exact quantity |
+| POST | `/api/v1/store/inventory/adjust` | Add/remove quantity |
+| POST | `/api/v1/store/inventory/reserve` | Reserve for order |
+| POST | `/api/v1/store/inventory/release` | Cancel reservation |
+| POST | `/api/v1/store/inventory/fulfill` | Complete order |
+| GET | `/api/v1/store/inventory/product/{id}` | Product summary |
+| GET | `/api/v1/store/inventory` | List with filters |
+| PUT | `/api/v1/store/inventory/{id}` | Update entry |
+| DELETE | `/api/v1/store/inventory/{id}` | Delete entry |
+
+### Admin Endpoints
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| GET | `/api/v1/admin/inventory` | List all (cross-store) |
+| GET | `/api/v1/admin/inventory/stats` | Platform statistics |
+| GET | `/api/v1/admin/inventory/low-stock` | Low stock alerts |
+| GET | `/api/v1/admin/inventory/stores` | Stores with inventory |
+| GET | `/api/v1/admin/inventory/locations` | Unique locations |
+| GET | `/api/v1/admin/inventory/stores/{id}` | Store inventory |
+| GET | `/api/v1/admin/inventory/products/{id}` | Product summary |
+| POST | `/api/v1/admin/inventory/set` | Set (requires store_id) |
+| POST | `/api/v1/admin/inventory/adjust` | Adjust (requires store_id) |
+| PUT | `/api/v1/admin/inventory/{id}` | Update entry |
+| DELETE | `/api/v1/admin/inventory/{id}` | Delete entry |
+
+---
+
+## Related Documentation
+
+- [Product Management](../catalog/architecture.md)
+- [Admin Inventory Migration Plan](../../implementation/inventory-admin-migration.md)
+- [Store Operations Expansion](../../development/migration/store-operations-expansion.md)
diff --git a/app/modules/loyalty/docs/business-logic.md b/app/modules/loyalty/docs/business-logic.md
new file mode 100644
index 00000000..dc0e9798
--- /dev/null
+++ b/app/modules/loyalty/docs/business-logic.md
@@ -0,0 +1,264 @@
+# Loyalty Business Logic
+
+Core algorithms, anti-fraud systems, and wallet integration logic for the loyalty module.
+
+## Anti-Fraud System
+
+The loyalty module implements a multi-layer fraud prevention system to prevent abuse of stamp and points operations.
+
+### Layer 1: Staff PIN Verification
+
+Every stamp/points operation can require a staff PIN. PINs are bcrypt-hashed and scoped to a specific store within a merchant.
+
+**Flow:**
+1. Staff enters 4-digit PIN on terminal
+2. System checks all active PINs for the program
+3. On match: records success, updates `last_used_at`
+4. On mismatch: increments `failed_attempts`
+5. After N failures (configurable, default 5): PIN is locked for M minutes (default 30)
+
+**PIN Policy** (set via `MerchantLoyaltySettings.staff_pin_policy`):
+
+| Policy | Behavior |
+|--------|----------|
+| `REQUIRED` | All stamp/point operations require PIN |
+| `OPTIONAL` | PIN can be provided but not required |
+| `DISABLED` | PIN entry is hidden from UI |
+
+### Layer 2: Stamp Cooldown
+
+Prevents rapid-fire stamping (e.g., customer stamps 10 times in one visit).
+
+- Configurable via `LoyaltyProgram.cooldown_minutes` (default: 15)
+- Checks `LoyaltyCard.last_stamp_at` against current time
+- Returns `next_stamp_available` timestamp in response
+
+### Layer 3: Daily Stamp Limits
+
+Prevents excessive stamps per day per card.
+
+- Configurable via `LoyaltyProgram.max_daily_stamps` (default: 5)
+- Counts today's `STAMP_EARNED` transactions for the card
+- Returns `remaining_stamps_today` in response
+
+### Layer 4: Audit Trail
+
+Every transaction records:
+- `staff_pin_id` — Which staff member verified
+- `store_id` — Which location
+- `ip_address` — Client IP (if `log_ip_addresses` enabled)
+- `user_agent` — Client device
+- `transaction_at` — Exact timestamp
+
+## Stamp Operations
+
+### Adding a Stamp
+
+```
+Input: card_id, staff_pin (optional), store_id
+Checks:
+ 1. Card is active
+ 2. Program is active and stamps-enabled
+ 3. Staff PIN valid (if required by policy)
+ 4. Cooldown elapsed since last_stamp_at
+ 5. Daily limit not reached
+Action:
+ - card.stamp_count += 1
+ - card.total_stamps_earned += 1
+ - card.last_stamp_at = now
+ - Create STAMP_EARNED transaction
+ - Sync wallet passes
+Output:
+ - stamp_count, stamps_target, stamps_until_reward
+ - reward_earned (true if stamp_count >= target)
+ - next_stamp_available, remaining_stamps_today
+```
+
+### Redeeming Stamps
+
+```
+Input: card_id, staff_pin (optional), store_id
+Checks:
+ 1. stamp_count >= stamps_target
+ 2. Staff PIN valid (if required)
+Action:
+ - card.stamp_count -= stamps_target (keeps overflow stamps)
+ - card.stamps_redeemed += 1
+ - Create STAMP_REDEEMED transaction (with reward_description)
+ - Sync wallet passes
+Output:
+ - success, reward_description, redemption_count
+ - remaining stamp_count after reset
+```
+
+### Voiding Stamps
+
+```
+Input: card_id, stamps_count OR transaction_id, staff_pin, store_id
+Checks:
+ 1. allow_void_transactions enabled in merchant settings
+ 2. Card has enough stamps to void
+ 3. Staff PIN valid (if required)
+Action:
+ - card.stamp_count -= stamps_count
+ - Create STAMP_VOIDED transaction (linked to original via related_transaction_id)
+ - Sync wallet passes
+```
+
+## Points Operations
+
+### Earning Points
+
+```
+Input: card_id, purchase_amount_cents, staff_pin, store_id, order_reference
+Calculation:
+ euros = purchase_amount_cents / 100
+ points = floor(euros × program.points_per_euro)
+Checks:
+ 1. Card is active, program is active and points-enabled
+ 2. Purchase amount >= minimum_purchase_cents (if configured)
+ 3. Order reference provided (if require_order_reference enabled)
+ 4. Staff PIN valid (if required)
+Action:
+ - card.points_balance += points
+ - card.total_points_earned += points
+ - Create POINTS_EARNED transaction (with purchase_amount_cents)
+ - Sync wallet passes
+Output:
+ - points_earned, points_balance, purchase_amount, points_per_euro
+```
+
+### Redeeming Points
+
+```
+Input: card_id, reward_id, staff_pin, store_id
+Checks:
+ 1. Reward exists in program.points_rewards
+ 2. card.points_balance >= reward.points_cost
+ 3. points_balance >= minimum_redemption_points (if configured)
+ 4. Staff PIN valid (if required)
+Action:
+ - card.points_balance -= reward.points_cost
+ - card.points_redeemed += reward.points_cost
+ - Create POINTS_REDEEMED transaction (with reward_id, reward_description)
+ - Sync wallet passes
+Output:
+ - reward name/description, points_spent, new balance
+```
+
+### Voiding Points
+
+```
+Input: card_id, transaction_id OR order_reference, staff_pin, store_id
+Checks:
+ 1. allow_void_transactions enabled
+ 2. Original transaction found and is an earn transaction
+ 3. Staff PIN valid (if required)
+Action:
+ - card.points_balance -= original points
+ - card.total_points_voided += original points
+ - Create POINTS_VOIDED transaction (linked via related_transaction_id)
+ - Sync wallet passes
+```
+
+### Adjusting Points
+
+Admin/store operation for manual corrections.
+
+```
+Input: card_id, points_delta (positive or negative), notes, store_id
+Action:
+ - card.points_balance += points_delta
+ - Create POINTS_ADJUSTMENT transaction with notes
+ - Sync wallet passes
+```
+
+## Wallet Integration
+
+### Google Wallet
+
+Uses the Google Wallet API with a service account for server-to-server communication.
+
+**Class (Program-level):**
+- One `LoyaltyClass` per program
+- Contains program name, branding (logo, hero), rewards info
+- Created when program is activated; updated when settings change
+
+**Object (Card-level):**
+- One `LoyaltyObject` per card
+- Contains balance (stamps or points), card number, member name
+- Created on enrollment; updated on every balance change
+- "Add to Wallet" URL is a JWT-signed save link
+
+### Apple Wallet
+
+Uses PKCS#7 signed `.pkpass` files and APNs push notifications.
+
+**Pass Generation:**
+1. Build `pass.json` with card data (stamps grid or points balance)
+2. Add icon/logo/strip images
+3. Create `manifest.json` (SHA256 of all files)
+4. Sign manifest with PKCS#7 using certificates and private key
+5. Package as `.pkpass` ZIP file
+
+**Push Updates:**
+1. When card balance changes, send APNs push to all registered devices
+2. Device receives push → requests updated pass from server
+3. Server generates fresh `.pkpass` with current balance
+
+**Device Registration (Apple Web Service protocol):**
+- `POST /v1/devices/{device}/registrations/{passType}/{serial}` — Register device
+- `DELETE /v1/devices/{device}/registrations/{passType}/{serial}` — Unregister device
+- `GET /v1/devices/{device}/registrations/{passType}` — List passes for device
+- `GET /v1/passes/{passType}/{serial}` — Get latest pass
+
+## Cross-Store Redemption
+
+When `allow_cross_location_redemption` is enabled in merchant settings:
+
+- Cards are scoped to the **merchant** (not individual stores)
+- Customer can earn stamps at Store A and redeem at Store B
+- Each transaction records which `store_id` it occurred at
+- The `enrolled_at_store_id` field tracks where the customer first enrolled
+
+When disabled, stamp/point operations are restricted to the enrollment store.
+
+## Enrollment Flow
+
+### Store-Initiated Enrollment
+
+Staff enrolls customer via terminal:
+1. Enter customer email (and optional name)
+2. System resolves or creates customer record
+3. Creates loyalty card with unique card number and QR code
+4. Creates `CARD_CREATED` transaction
+5. Awards welcome bonus points (if configured) via `WELCOME_BONUS` transaction
+6. Creates Google Wallet object and Apple Wallet serial
+7. Returns card details with "Add to Wallet" URLs
+
+### Self-Enrollment (Public)
+
+Customer enrolls via public page (if `allow_self_enrollment` enabled):
+1. Customer visits `/loyalty/join` page
+2. Enters email and name
+3. System creates customer + card
+4. Redirected to success page with card number
+5. Can add to Google/Apple Wallet from success page
+
+## Scheduled Tasks
+
+| Task | Schedule | Logic |
+|------|----------|-------|
+| `loyalty.sync_wallet_passes` | Hourly | Re-sync cards that missed real-time wallet updates |
+| `loyalty.expire_points` | Daily 02:00 | Find cards with `points_expiration_days` set and no activity within that window; create `POINTS_EXPIRED` transaction |
+
+## Feature Gating
+
+The loyalty module declares these billable features via `LoyaltyFeatureProvider`:
+
+- `loyalty_stamps`, `loyalty_points`, `loyalty_hybrid`
+- `loyalty_cards`, `loyalty_enrollment`, `loyalty_staff_pins`
+- `loyalty_anti_fraud`, `loyalty_google_wallet`, `loyalty_apple_wallet`
+- `loyalty_stats`, `loyalty_reports`
+
+These integrate with the [billing module's feature gating system](../billing/feature-gating.md) to control access based on subscription tier.
diff --git a/app/modules/loyalty/docs/data-model.md b/app/modules/loyalty/docs/data-model.md
new file mode 100644
index 00000000..b1f07ed4
--- /dev/null
+++ b/app/modules/loyalty/docs/data-model.md
@@ -0,0 +1,235 @@
+# Loyalty Data Model
+
+Entity relationships and database schema for the loyalty module.
+
+## Entity Relationship Diagram
+
+```
+┌──────────────────────┐
+│ Merchant │ (from tenancy module)
+│ (one program per │
+│ merchant) │
+└──────────┬───────────┘
+ │ 1
+ │
+ ┌─────┴─────┐
+ │ │
+ ▼ 1 ▼ 1
+┌──────────┐ ┌──────────────────────┐
+│ Loyalty │ │ MerchantLoyalty │
+│ Program │ │ Settings │
+│ │ │ │
+│ type │ │ staff_pin_policy │
+│ stamps │ │ allow_self_enrollment│
+│ points │ │ allow_void │
+│ branding │ │ allow_cross_location │
+│ anti- │ │ require_order_ref │
+│ fraud │ │ log_ip_addresses │
+└──┬───┬───┘ └──────────────────────┘
+ │ │
+ │ │ 1..*
+ │ ▼
+ │ ┌──────────────┐
+ │ │ StaffPin │
+ │ │ │
+ │ │ name │
+ │ │ pin_hash │ (bcrypt)
+ │ │ store_id │
+ │ │ failed_ │
+ │ │ attempts │
+ │ │ locked_until │
+ │ └──────────────┘
+ │
+ │ 1..*
+ ▼
+┌──────────────────┐ ┌──────────────────┐
+│ LoyaltyCard │ │ Customer │ (from customers module)
+│ │ *───1 │ │
+│ card_number │ └──────────────────┘
+│ qr_code_data │
+│ stamp_count │ ┌──────────────────┐
+│ points_balance │ │ Store │ (from tenancy module)
+│ google_object_id│ *───1 │ (enrolled_at) │
+│ apple_serial │ └──────────────────┘
+│ is_active │
+└──────┬───────────┘
+ │
+ │ 1..*
+ ▼
+┌──────────────────────┐
+│ LoyaltyTransaction │ (immutable audit log)
+│ │
+│ transaction_type │
+│ stamps_delta │ (signed: +1 earn, -N redeem)
+│ points_delta │ (signed: +N earn, -N redeem)
+│ stamps_balance_after│
+│ points_balance_after│
+│ purchase_amount │
+│ staff_pin_id │──── FK to StaffPin
+│ store_id │──── FK to Store (location)
+│ related_txn_id │──── FK to self (for voids)
+│ ip_address │
+│ user_agent │
+└──────────────────────┘
+
+┌──────────────────────────┐
+│ AppleDeviceRegistration │
+│ │
+│ card_id │──── FK to LoyaltyCard
+│ device_library_id │
+│ push_token │
+│ │
+│ UNIQUE(device, card) │
+└──────────────────────────┘
+```
+
+## Models
+
+### LoyaltyProgram
+
+Merchant-wide loyalty program configuration. One program per merchant, shared across all stores.
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `merchant_id` | FK (unique) | One program per merchant |
+| `loyalty_type` | Enum | STAMPS, POINTS, or HYBRID |
+| `stamps_target` | Integer | Stamps needed for reward |
+| `stamps_reward_description` | String | Reward description text |
+| `stamps_reward_value_cents` | Integer | Reward monetary value |
+| `points_per_euro` | Integer | Points earned per euro spent |
+| `points_rewards` | JSON | Reward catalog (id, name, points_cost) |
+| `points_expiration_days` | Integer | Days until points expire (nullable) |
+| `welcome_bonus_points` | Integer | Points given on enrollment |
+| `minimum_redemption_points` | Integer | Minimum points to redeem |
+| `minimum_purchase_cents` | Integer | Minimum purchase for earning |
+| `cooldown_minutes` | Integer | Minutes between stamps (anti-fraud) |
+| `max_daily_stamps` | Integer | Max stamps per card per day |
+| `require_staff_pin` | Boolean | Whether PIN is required |
+| `card_name` | String | Display name on card |
+| `card_color` | String | Primary brand color (hex) |
+| `card_secondary_color` | String | Secondary brand color (hex) |
+| `logo_url` | String | Logo image URL |
+| `hero_image_url` | String | Hero/banner image URL |
+| `google_issuer_id` | String | Google Wallet issuer ID |
+| `google_class_id` | String | Google Wallet class ID |
+| `apple_pass_type_id` | String | Apple Wallet pass type identifier |
+| `terms_text` | Text | Terms and conditions |
+| `privacy_url` | String | Privacy policy URL |
+| `is_active` | Boolean | Whether program is live |
+| `activated_at` | DateTime | When program was activated |
+
+### LoyaltyCard
+
+Customer loyalty card linking a customer to a merchant's program. One card per customer per merchant.
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `merchant_id` | FK | Links to program's merchant |
+| `customer_id` | FK | Card owner |
+| `program_id` | FK | Associated program |
+| `enrolled_at_store_id` | FK | Store where customer enrolled |
+| `card_number` | String (unique) | Formatted XXXX-XXXX-XXXX |
+| `qr_code_data` | String (unique) | URL-safe token for QR codes |
+| `stamp_count` | Integer | Current stamp count |
+| `total_stamps_earned` | Integer | Lifetime stamps earned |
+| `stamps_redeemed` | Integer | Total redemptions |
+| `points_balance` | Integer | Current points balance |
+| `total_points_earned` | Integer | Lifetime points earned |
+| `points_redeemed` | Integer | Total points redeemed |
+| `total_points_voided` | Integer | Total points voided |
+| `google_object_id` | String | Google Wallet object ID |
+| `google_object_jwt` | Text | Google Wallet JWT |
+| `apple_serial_number` | String | Apple Wallet serial number |
+| `apple_auth_token` | String | Apple Wallet auth token |
+| `last_stamp_at` | DateTime | Last stamp timestamp |
+| `last_points_at` | DateTime | Last points timestamp |
+| `last_redemption_at` | DateTime | Last redemption timestamp |
+| `last_activity_at` | DateTime | Last activity of any kind |
+| `is_active` | Boolean | Whether card is active |
+
+### LoyaltyTransaction
+
+Immutable audit log of all loyalty operations. Every stamp, point, redemption, and void is recorded.
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `merchant_id` | FK | Merchant program owner |
+| `card_id` | FK | Affected card |
+| `store_id` | FK | Store where transaction occurred |
+| `staff_pin_id` | FK (nullable) | Staff who verified |
+| `related_transaction_id` | FK (nullable) | For void/return linking |
+| `transaction_type` | Enum | See transaction types below |
+| `stamps_delta` | Integer | Signed stamp change |
+| `points_delta` | Integer | Signed points change |
+| `stamps_balance_after` | Integer | Stamp count after transaction |
+| `points_balance_after` | Integer | Points balance after transaction |
+| `purchase_amount_cents` | Integer | Purchase amount for points earning |
+| `order_reference` | String | External order reference |
+| `reward_id` | String | Redeemed reward identifier |
+| `reward_description` | String | Redeemed reward description |
+| `ip_address` | String | Client IP (audit) |
+| `user_agent` | String | Client user agent (audit) |
+| `notes` | Text | Staff/admin notes |
+| `transaction_at` | DateTime | When transaction occurred |
+
+**Transaction Types:**
+
+| Type | Category | Description |
+|------|----------|-------------|
+| `STAMP_EARNED` | Stamps | Customer earned a stamp |
+| `STAMP_REDEEMED` | Stamps | Stamps exchanged for reward |
+| `STAMP_VOIDED` | Stamps | Stamp reversed (return) |
+| `STAMP_ADJUSTMENT` | Stamps | Manual adjustment |
+| `POINTS_EARNED` | Points | Points from purchase |
+| `POINTS_REDEEMED` | Points | Points exchanged for reward |
+| `POINTS_VOIDED` | Points | Points reversed (return) |
+| `POINTS_ADJUSTMENT` | Points | Manual adjustment |
+| `POINTS_EXPIRED` | Points | Points expired due to inactivity |
+| `CARD_CREATED` | Lifecycle | Card enrollment |
+| `CARD_DEACTIVATED` | Lifecycle | Card deactivated |
+| `WELCOME_BONUS` | Bonus | Welcome bonus points on enrollment |
+
+### StaffPin
+
+Staff authentication PINs for fraud prevention. Scoped to a store within a merchant's program.
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `merchant_id` | FK | Merchant |
+| `program_id` | FK | Associated program |
+| `store_id` | FK | Store this PIN is for |
+| `name` | String | Staff member name |
+| `staff_id` | String | External staff identifier |
+| `pin_hash` | String | Bcrypt-hashed PIN |
+| `failed_attempts` | Integer | Consecutive failed attempts |
+| `locked_until` | DateTime | Lockout expiry (nullable) |
+| `last_used_at` | DateTime | Last successful use |
+| `is_active` | Boolean | Whether PIN is active |
+
+### MerchantLoyaltySettings
+
+Admin-controlled settings for a merchant's loyalty program. Separate from program config to allow admin overrides.
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `merchant_id` | FK (unique) | One settings record per merchant |
+| `staff_pin_policy` | Enum | REQUIRED, OPTIONAL, or DISABLED |
+| `staff_pin_lockout_attempts` | Integer | Failed attempts before lockout |
+| `staff_pin_lockout_minutes` | Integer | Lockout duration |
+| `allow_self_enrollment` | Boolean | Whether customers can self-enroll |
+| `allow_void_transactions` | Boolean | Whether voids are allowed |
+| `allow_cross_location_redemption` | Boolean | Cross-store redemption |
+| `require_order_reference` | Boolean | Require order ref for points |
+| `log_ip_addresses` | Boolean | Log IPs in transactions |
+
+### AppleDeviceRegistration
+
+Tracks Apple devices registered for wallet push notifications when card balances change.
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `card_id` | FK | Associated loyalty card |
+| `device_library_identifier` | String | Apple device identifier |
+| `push_token` | String | APNs push token |
+
+Unique constraint on `(device_library_identifier, card_id)`.
diff --git a/app/modules/loyalty/docs/index.md b/app/modules/loyalty/docs/index.md
new file mode 100644
index 00000000..fb1ab80c
--- /dev/null
+++ b/app/modules/loyalty/docs/index.md
@@ -0,0 +1,110 @@
+# Loyalty Programs
+
+Stamp-based and points-based loyalty programs with Google Wallet and Apple Wallet integration. Includes anti-fraud features like staff PINs, cooldown periods, and daily limits.
+
+## Overview
+
+| Aspect | Detail |
+|--------|--------|
+| Code | `loyalty` |
+| Classification | Optional |
+| Dependencies | `customers` |
+| Status | Active |
+
+## Features
+
+- `loyalty_stamps` — Stamp-based loyalty (collect N, get reward)
+- `loyalty_points` — Points-based loyalty (earn per euro spent)
+- `loyalty_hybrid` — Combined stamps and points programs
+- `loyalty_cards` — Digital loyalty card management
+- `loyalty_enrollment` — Customer enrollment flow
+- `loyalty_staff_pins` — Staff PIN verification
+- `loyalty_anti_fraud` — Cooldown periods, daily limits, lockout protection
+- `loyalty_google_wallet` — Google Wallet pass integration
+- `loyalty_apple_wallet` — Apple Wallet pass integration
+- `loyalty_stats` — Program statistics
+- `loyalty_reports` — Loyalty reporting
+
+## Permissions
+
+| Permission | Description |
+|------------|-------------|
+| `loyalty.view_programs` | View loyalty programs |
+| `loyalty.manage_programs` | Create/edit loyalty programs |
+| `loyalty.view_rewards` | View rewards |
+| `loyalty.manage_rewards` | Manage rewards |
+
+## Data Model
+
+See [Data Model](data-model.md) for full entity relationships.
+
+- **LoyaltyProgram** — Program configuration (type, targets, branding)
+- **LoyaltyCard** — Customer cards with stamp/point balances
+- **LoyaltyTransaction** — Immutable audit log of all operations
+- **StaffPin** — Hashed PINs for fraud prevention
+- **MerchantLoyaltySettings** — Admin-controlled merchant settings
+- **AppleDeviceRegistration** — Apple Wallet push notification tokens
+
+## API Endpoints
+
+### Store Endpoints (`/api/v1/store/loyalty/`)
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| `GET` | `/program` | Get store's loyalty program |
+| `POST` | `/program` | Create loyalty program |
+| `PATCH` | `/program` | Update loyalty program |
+| `GET` | `/stats` | Get program statistics |
+| `GET` | `/cards` | List customer cards |
+| `POST` | `/cards/enroll` | Enroll customer in program |
+| `POST` | `/stamp` | Add stamp to card |
+| `POST` | `/stamp/redeem` | Redeem stamps for reward |
+| `POST` | `/points` | Earn points from purchase |
+| `POST` | `/points/redeem` | Redeem points for reward |
+| `*` | `/pins/*` | Staff PIN management |
+
+### Admin Endpoints (`/api/v1/admin/loyalty/`)
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| `GET` | `/programs` | List all loyalty programs |
+| `GET` | `/programs/{id}` | Get specific program |
+| `GET` | `/stats` | Platform-wide statistics |
+
+### Storefront Endpoints (`/api/v1/storefront/loyalty/`)
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| `GET` | `/card` | Get customer's loyalty card |
+| `GET` | `/transactions` | Transaction history |
+| `POST` | `/enroll` | Self-enrollment |
+
+## Scheduled Tasks
+
+| Task | Schedule | Description |
+|------|----------|-------------|
+| `loyalty.sync_wallet_passes` | Hourly | Sync cards that missed real-time updates |
+| `loyalty.expire_points` | Daily 02:00 | Expire points for inactive cards |
+
+## Configuration
+
+Environment variables (prefix: `LOYALTY_`):
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `LOYALTY_DEFAULT_COOLDOWN_MINUTES` | 15 | Cooldown between stamps |
+| `LOYALTY_MAX_DAILY_STAMPS` | 5 | Max stamps per card per day |
+| `LOYALTY_PIN_MAX_FAILED_ATTEMPTS` | 5 | PIN lockout threshold |
+| `LOYALTY_PIN_LOCKOUT_MINUTES` | 30 | PIN lockout duration |
+| `LOYALTY_DEFAULT_POINTS_PER_EURO` | 10 | Points earned per euro |
+| `LOYALTY_GOOGLE_ISSUER_ID` | — | Google Wallet issuer ID |
+| `LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON` | — | Google service account path |
+| `LOYALTY_APPLE_*` | — | Apple Wallet certificate paths |
+
+## Additional Documentation
+
+- [Data Model](data-model.md) — Entity relationships and database schema
+- [Business Logic](business-logic.md) — Anti-fraud system, wallet integration, enrollment flow
+- [User Journeys](user-journeys.md) — Detailed user journey flows with dev/prod URLs
+- [Program Analysis](program-analysis.md) — Business analysis and platform vision
+- [UI Design](ui-design.md) — Admin and store interface mockups and implementation roadmap
diff --git a/app/modules/loyalty/docs/program-analysis.md b/app/modules/loyalty/docs/program-analysis.md
new file mode 100644
index 00000000..10506bed
--- /dev/null
+++ b/app/modules/loyalty/docs/program-analysis.md
@@ -0,0 +1,387 @@
+# Loyalty Program Platform - Business Analysis
+
+**Session Date:** 2026-01-13
+**Status:** Initial Analysis - Pending Discussion
+**Next Steps:** Resume discussion to clarify requirements
+
+---
+
+## Executive Summary
+
+Multiple retailers have expressed interest in a loyalty program application. This document analyzes how the current OMS platform could be leveraged to provide a loyalty program offering as a new product line.
+
+---
+
+## Business Proposal Overview
+
+### Concept
+- **Multi-platform offering**: Different platform tiers (A, B, C) with varying feature sets
+- **Target clients**: Merchants (retailers) with one or multiple shops
+- **Core functionality**:
+ - Customer email collection
+ - Promotions and campaigns
+ - Discounts and rewards
+ - Points accumulation
+
+### Platform Architecture Vision
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ PLATFORM LEVEL │
+│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
+│ │ Platform A │ │ Platform B │ │ Platform C │ ... │
+│ │ (Loyalty+) │ │ (Basic) │ │ (Enterprise) │ │
+│ └──────────────┘ └──────────────┘ └──────────────┘ │
+└─────────────────────────────────────────────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────────────────────────────────┐
+│ CLIENT LEVEL (Merchant) │
+│ ┌──────────────────────────────────────────────────────────┐ │
+│ │ Retailer X (e.g., Bakery Chain) │ │
+│ │ ├── Shop 1 (Luxembourg City) │ │
+│ │ ├── Shop 2 (Esch) │ │
+│ │ └── Shop 3 (Differdange) │ │
+│ └──────────────────────────────────────────────────────────┘ │
+└─────────────────────────────────────────────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────────────────────────────────┐
+│ CUSTOMER LEVEL │
+│ • Email collection • Points accumulation │
+│ • Promotions/Offers • Discounts/Rewards │
+│ • Purchase history • Tier status │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+---
+
+## Current OMS Architecture Leverage
+
+The existing platform has several components that map directly to loyalty program needs:
+
+| Current OMS Component | Loyalty Program Use |
+|-----------------------|---------------------|
+| `Merchant` model | Client (retailer chain) |
+| `Store` model | Individual shop/location |
+| `Customer` model | Loyalty member base |
+| `Order` model | Transaction for points calculation |
+| `User` (store role) | Shop staff for check-in/redemption |
+| Multi-tenant auth | Per-client data isolation |
+| Admin dashboard | Retailer management interface |
+| Store dashboard | Shop-level operations |
+| API infrastructure | Integration capabilities |
+
+### Existing Infrastructure Benefits
+- Authentication & authorization system
+- Multi-tenant data isolation
+- Merchant → Store hierarchy
+- Customer management
+- Email/notification system (if exists)
+- Celery background tasks
+- API patterns established
+
+---
+
+## New Components Required
+
+### 1. Core Loyalty Models
+
+```python
+# New database models needed
+
+LoyaltyProgram
+ - id
+ - merchant_id (FK)
+ - name
+ - points_per_euro (Decimal)
+ - points_expiry_days (Integer, nullable)
+ - is_active (Boolean)
+ - settings (JSON) - flexible configuration
+
+LoyaltyMember
+ - id
+ - customer_id (FK to existing Customer)
+ - loyalty_program_id (FK)
+ - points_balance (Integer)
+ - lifetime_points (Integer)
+ - tier_id (FK)
+ - enrolled_at (DateTime)
+ - last_activity_at (DateTime)
+
+LoyaltyTier
+ - id
+ - loyalty_program_id (FK)
+ - name (e.g., "Bronze", "Silver", "Gold")
+ - min_points_required (Integer)
+ - benefits (JSON)
+ - sort_order (Integer)
+
+LoyaltyTransaction
+ - id
+ - member_id (FK)
+ - store_id (FK) - which shop
+ - transaction_type (ENUM: earn, redeem, expire, adjust)
+ - points (Integer, positive or negative)
+ - reference_type (e.g., "order", "promotion", "manual")
+ - reference_id (Integer, nullable)
+ - description (String)
+ - created_at (DateTime)
+ - created_by_user_id (FK, nullable)
+
+Promotion
+ - id
+ - loyalty_program_id (FK)
+ - name
+ - description
+ - promotion_type (ENUM: bonus_points, discount_percent, discount_fixed, free_item)
+ - value (Decimal)
+ - conditions (JSON) - min spend, specific products, etc.
+ - start_date (DateTime)
+ - end_date (DateTime)
+ - max_redemptions (Integer, nullable)
+ - is_active (Boolean)
+
+PromotionRedemption
+ - id
+ - promotion_id (FK)
+ - member_id (FK)
+ - store_id (FK)
+ - redeemed_at (DateTime)
+ - order_id (FK, nullable)
+
+Reward
+ - id
+ - loyalty_program_id (FK)
+ - name
+ - description
+ - points_cost (Integer)
+ - reward_type (ENUM: discount, free_product, voucher)
+ - value (Decimal or JSON)
+ - is_active (Boolean)
+ - stock (Integer, nullable) - for limited rewards
+```
+
+### 2. Platform Offering Tiers
+
+```python
+# Platform-level configuration
+
+class PlatformOffering(Enum):
+ BASIC = "basic"
+ PLUS = "plus"
+ ENTERPRISE = "enterprise"
+
+# Feature matrix per offering
+OFFERING_FEATURES = {
+ "basic": {
+ "max_shops": 1,
+ "points_earning": True,
+ "basic_promotions": True,
+ "tiers": False,
+ "custom_rewards": False,
+ "api_access": False,
+ "white_label": False,
+ "analytics": "basic",
+ },
+ "plus": {
+ "max_shops": 10,
+ "points_earning": True,
+ "basic_promotions": True,
+ "tiers": True,
+ "custom_rewards": True,
+ "api_access": False,
+ "white_label": False,
+ "analytics": "advanced",
+ },
+ "enterprise": {
+ "max_shops": None, # Unlimited
+ "points_earning": True,
+ "basic_promotions": True,
+ "tiers": True,
+ "custom_rewards": True,
+ "api_access": True,
+ "white_label": True,
+ "analytics": "full",
+ },
+}
+```
+
+### 3. Feature Matrix
+
+| Feature | Basic | Plus | Enterprise |
+|---------|:-----:|:----:|:----------:|
+| Customer email collection | ✓ | ✓ | ✓ |
+| Points earning | ✓ | ✓ | ✓ |
+| Basic promotions | ✓ | ✓ | ✓ |
+| Multi-shop support | 1 shop | Up to 10 | Unlimited |
+| Tier system (Bronze/Silver/Gold) | - | ✓ | ✓ |
+| Custom rewards catalog | - | ✓ | ✓ |
+| API access | - | - | ✓ |
+| White-label branding | - | - | ✓ |
+| Analytics dashboard | Basic | Advanced | Full |
+| Customer segmentation | - | ✓ | ✓ |
+| Email campaigns | - | ✓ | ✓ |
+| Dedicated support | - | - | ✓ |
+
+---
+
+## Implementation Options
+
+### Option A: Standalone Application
+- Separate codebase
+- Shares database patterns but independent deployment
+- **Pros**: Clean separation, can scale independently
+- **Cons**: Duplication of auth, admin patterns; more maintenance
+
+### Option B: Module in Current OMS (Recommended)
+- Add loyalty as a feature module within existing platform
+- Leverages existing infrastructure
+
+**Proposed directory structure:**
+```
+letzshop-product-import/
+├── app/
+│ ├── api/v1/
+│ │ ├── loyalty/ # NEW
+│ │ │ ├── __init__.py
+│ │ │ ├── programs.py # Program CRUD
+│ │ │ ├── members.py # Member management
+│ │ │ ├── transactions.py # Points transactions
+│ │ │ ├── promotions.py # Promotion management
+│ │ │ ├── rewards.py # Rewards catalog
+│ │ │ └── public.py # Customer-facing endpoints
+│ │ │
+│ ├── services/
+│ │ ├── loyalty/ # NEW
+│ │ │ ├── __init__.py
+│ │ │ ├── points_service.py # Points calculation logic
+│ │ │ ├── tier_service.py # Tier management
+│ │ │ ├── promotion_service.py # Promotion rules engine
+│ │ │ └── reward_service.py # Reward redemption
+│ │ │
+│ ├── templates/
+│ │ ├── loyalty/ # NEW - if web UI needed
+│ │ │ ├── admin/ # Platform admin views
+│ │ │ ├── retailer/ # Retailer dashboard
+│ │ │ └── member/ # Customer-facing portal
+│ │ │
+├── models/
+│ ├── database/
+│ │ ├── loyalty.py # NEW - All loyalty models
+│ ├── schema/
+│ │ ├── loyalty.py # NEW - Pydantic schemas
+```
+
+---
+
+## Open Questions (To Discuss)
+
+### 1. Points Model
+- **Q1.1**: Fixed points per euro spent? (e.g., 1 point = €0.10 spent)
+- **Q1.2**: Variable points by product category? (e.g., 2x points on bakery items)
+- **Q1.3**: Bonus points for specific actions? (e.g., sign-up bonus, birthday bonus)
+- **Q1.4**: Points expiration policy? (e.g., expire after 12 months of inactivity)
+
+### 2. Redemption Methods
+- **Q2.1**: In-store redemption only? (requires POS integration or staff app)
+- **Q2.2**: Online shop redemption?
+- **Q2.3**: Both in-store and online?
+- **Q2.4**: What POS systems do target retailers use?
+
+### 3. Customer Identification
+- **Q3.1**: Email only?
+- **Q3.2**: Phone number as alternative?
+- **Q3.3**: Physical loyalty card with barcode/QR?
+- **Q3.4**: Mobile app with digital card?
+- **Q3.5**: Integration with existing customer accounts?
+
+### 4. Multi-Platform Architecture
+- **Q4.1**: Different domains per offering tier?
+ - e.g., loyalty-basic.lu, loyalty-pro.lu, loyalty-enterprise.lu
+- **Q4.2**: Same domain with feature flags based on subscription?
+- **Q4.3**: White-label with custom domains for enterprise clients?
+
+### 5. Data & Privacy
+- **Q5.1**: Can retailers see each other's customers? (Assumed: No)
+- **Q5.2**: Can a customer be enrolled in multiple loyalty programs? (Different retailers)
+- **Q5.3**: GDPR considerations for customer data?
+- **Q5.4**: Data export/portability requirements?
+
+### 6. Business Model
+- **Q6.1**: Pricing model? (Monthly subscription, per-transaction fee, hybrid?)
+- **Q6.2**: Free trial period?
+- **Q6.3**: Upgrade/downgrade path between tiers?
+
+### 7. Integration Requirements
+- **Q7.1**: POS system integrations needed?
+- **Q7.2**: Email marketing platform integration? (Mailchimp, SendGrid, etc.)
+- **Q7.3**: SMS notifications?
+- **Q7.4**: Accounting/invoicing integration?
+
+### 8. MVP Scope
+- **Q8.1**: What is the minimum viable feature set for first launch?
+- **Q8.2**: Which offering tier to build first?
+- **Q8.3**: Target timeline?
+- **Q8.4**: Pilot retailers identified?
+
+---
+
+## Potential User Flows
+
+### Retailer Onboarding Flow
+1. Retailer signs up on platform
+2. Selects offering tier (Basic/Plus/Enterprise)
+3. Configures loyalty program (name, points ratio, branding)
+4. Adds shop locations
+5. Invites staff members
+6. Sets up initial promotions
+7. Goes live
+
+### Customer Enrollment Flow
+1. Customer visits shop or website
+2. Provides email (and optionally phone)
+3. Receives welcome email with member ID/card
+4. Starts earning points on purchases
+
+### Points Earning Flow (In-Store)
+1. Customer makes purchase
+2. Staff asks for loyalty member ID (email, phone, or card scan)
+3. System calculates points based on purchase amount
+4. Points credited to member account
+5. Receipt shows points earned and balance
+
+### Reward Redemption Flow
+1. Customer views available rewards (app/web/in-store)
+2. Selects reward to redeem
+3. System validates sufficient points
+4. Generates redemption code/voucher
+5. Customer uses at checkout
+6. Points deducted from balance
+
+---
+
+## Next Steps
+
+1. **Clarify requirements** - Answer open questions above
+2. **Define MVP scope** - What's the minimum for first launch?
+3. **Technical design** - Database schema, API design
+4. **UI/UX design** - Retailer dashboard, customer portal
+5. **Implementation plan** - Phased approach
+6. **Pilot program** - Identify first retailers for beta
+
+---
+
+## Session Notes
+
+### 2026-01-13
+- Initial business proposal discussion
+- Analyzed current OMS architecture fit
+- Identified reusable components
+- Outlined new models needed
+- Documented open questions
+- **Action**: Resume discussion to clarify requirements
+
+---
+
+*Document created for session continuity. Update as discussions progress.*
diff --git a/app/modules/loyalty/docs/ui-design.md b/app/modules/loyalty/docs/ui-design.md
new file mode 100644
index 00000000..4da2df90
--- /dev/null
+++ b/app/modules/loyalty/docs/ui-design.md
@@ -0,0 +1,670 @@
+# Loyalty Module Phase 2: Admin & Store Interfaces
+
+## Executive Summary
+
+This document outlines the plan for building admin and store interfaces for the Loyalty Module, along with detailed user journeys for stamp-based and points-based loyalty programs. The design follows market best practices from leading loyalty platforms (Square Loyalty, Toast, Fivestars, Belly, Punchh).
+
+---
+
+## Part 1: Interface Design
+
+### 1.1 Store Dashboard (Retail Store)
+
+#### Main Loyalty Dashboard (`/store/loyalty`)
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ 🎯 Loyalty Program [Setup] │
+├─────────────────────────────────────────────────────────────────┤
+│ │
+│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────┐│
+│ │ 1,247 │ │ 892 │ │ 156 │ │ €2.3k ││
+│ │ Members │ │ Active │ │ Redeemed │ │ Saved ││
+│ │ Total │ │ 30 days │ │ This Month │ │ Value ││
+│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────┘│
+│ │
+│ ┌─────────────────────────────────────────────────────────────┐│
+│ │ 📊 Activity Chart (Last 30 Days) ││
+│ │ [Stamps Issued] [Rewards Redeemed] [New Members] ││
+│ │ ═══════════════════════════════════════════════ ││
+│ └─────────────────────────────────────────────────────────────┘│
+│ │
+│ ┌─────────────────────────┐ ┌─────────────────────────────┐ │
+│ │ 🔥 Quick Actions │ │ 📋 Recent Activity │ │
+│ │ │ │ │ │
+│ │ [➕ Add Stamp] │ │ • John D. earned stamp #8 │ │
+│ │ [🎁 Redeem Reward] │ │ • Marie L. redeemed reward │ │
+│ │ [👤 Enroll Customer] │ │ • Alex K. joined program │ │
+│ │ [🔍 Look Up Card] │ │ • Sarah M. earned 50 pts │ │
+│ │ │ │ │ │
+│ └─────────────────────────┘ └─────────────────────────────┘ │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+#### Stamp/Points Terminal (`/store/loyalty/terminal`)
+
+**Primary interface for daily operations - optimized for tablet/touchscreen:**
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ 🎯 Loyalty Terminal │
+├─────────────────────────────────────────────────────────────────┤
+│ │
+│ ┌───────────────────────────────────────────────────┐ │
+│ │ │ │
+│ │ 📷 SCAN QR CODE │ │
+│ │ │ │
+│ │ [Camera Viewfinder Area] │ │
+│ │ │ │
+│ │ or enter card number │ │
+│ │ ┌─────────────────────────┐ │ │
+│ │ │ Card Number... │ │ │
+│ │ └─────────────────────────┘ │ │
+│ └───────────────────────────────────────────────────┘ │
+│ │
+│ [Use Camera] [Enter Manually] [Recent Cards ▼] │
+│ │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+**After scanning - Customer Card View:**
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ ← Back Customer Card │
+├─────────────────────────────────────────────────────────────────┤
+│ │
+│ ┌─────────────────────────────────────────────────┐ │
+│ │ 👤 Marie Laurent │ │
+│ │ marie.laurent@email.com │ │
+│ │ Member since: Jan 2024 │ │
+│ └─────────────────────────────────────────────────┘ │
+│ │
+│ ┌─────────────────────────────────────────────────┐ │
+│ │ │ │
+│ │ ☕ ☕ ☕ ☕ ☕ ☕ ☕ ☕ ○ ○ │ │
+│ │ │ │
+│ │ 8 / 10 stamps │ │
+│ │ 2 more until FREE COFFEE │ │
+│ │ │ │
+│ └─────────────────────────────────────────────────┘ │
+│ │
+│ ┌─────────────────────────────────────────────────┐ │
+│ │ │ │
+│ │ [ ➕ ADD STAMP ] [ 🎁 REDEEM ] │ │
+│ │ │ │
+│ └─────────────────────────────────────────────────┘ │
+│ │
+│ ⚠️ Next stamp available in 12 minutes │
+│ │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+**PIN Entry Modal (appears when adding stamp):**
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ Enter Staff PIN │
+├─────────────────────────────────────────────────────────────────┤
+│ │
+│ ┌─────────────────┐ │
+│ │ ● ● ● ● │ │
+│ └─────────────────┘ │
+│ │
+│ ┌─────┐ ┌─────┐ ┌─────┐ │
+│ │ 1 │ │ 2 │ │ 3 │ │
+│ └─────┘ └─────┘ └─────┘ │
+│ ┌─────┐ ┌─────┐ ┌─────┐ │
+│ │ 4 │ │ 5 │ │ 6 │ │
+│ └─────┘ └─────┘ └─────┘ │
+│ ┌─────┐ ┌─────┐ ┌─────┐ │
+│ │ 7 │ │ 8 │ │ 9 │ │
+│ └─────┘ └─────┘ └─────┘ │
+│ ┌─────┐ ┌─────┐ ┌─────┐ │
+│ │ ⌫ │ │ 0 │ │ ✓ │ │
+│ └─────┘ └─────┘ └─────┘ │
+│ │
+│ [Cancel] │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+#### Program Setup (`/store/loyalty/settings`)
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ ⚙️ Loyalty Program Settings │
+├─────────────────────────────────────────────────────────────────┤
+│ │
+│ Program Type │
+│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
+│ │ ☑️ Stamps │ │ ☐ Points │ │ ☐ Hybrid │ │
+│ │ Buy 10 Get 1 │ │ Earn per € │ │ Both systems │ │
+│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
+│ │
+│ ───────────────────────────────────────────────────────────── │
+│ │
+│ Stamp Configuration │
+│ ┌─────────────────────────────────────────────────────────┐ │
+│ │ Stamps needed for reward: [ 10 ▼ ] │ │
+│ │ Reward description: [ Free coffee of choice ] │ │
+│ │ Reward value (optional): [ €4.50 ] │ │
+│ └─────────────────────────────────────────────────────────┘ │
+│ │
+│ ───────────────────────────────────────────────────────────── │
+│ │
+│ 🛡️ Fraud Prevention │
+│ ┌─────────────────────────────────────────────────────────┐ │
+│ │ ☑️ Require staff PIN for operations │ │
+│ │ Cooldown between stamps: [ 15 ] minutes │ │
+│ │ Max stamps per day: [ 5 ] │ │
+│ └─────────────────────────────────────────────────────────┘ │
+│ │
+│ ───────────────────────────────────────────────────────────── │
+│ │
+│ 🎨 Card Branding │
+│ ┌─────────────────────────────────────────────────────────┐ │
+│ │ Card name: [ Café Loyalty Card ] │ │
+│ │ Primary color: [████] #4F46E5 │ │
+│ │ Logo: [Upload] cafe-logo.png ✓ │ │
+│ │ │ │
+│ │ Preview: ┌────────────────────┐ │ │
+│ │ │ ☕ Café Loyalty │ │ │
+│ │ │ ████████░░ │ │ │
+│ │ │ 8/10 stamps │ │ │
+│ │ └────────────────────┘ │ │
+│ └─────────────────────────────────────────────────────────┘ │
+│ │
+│ [Cancel] [Save Changes] │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+#### Staff PIN Management (`/store/loyalty/pins`)
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ 🔐 Staff PINs [+ Add PIN] │
+├─────────────────────────────────────────────────────────────────┤
+│ │
+│ ┌─────────────────────────────────────────────────────────┐ │
+│ │ 👤 Marie (Manager) [Edit] [🗑️] │ │
+│ │ Last used: Today, 14:32 │ │
+│ │ Status: ✅ Active │ │
+│ ├─────────────────────────────────────────────────────────┤ │
+│ │ 👤 Thomas (Staff) [Edit] [🗑️] │ │
+│ │ Last used: Today, 11:15 │ │
+│ │ Status: ✅ Active │ │
+│ ├─────────────────────────────────────────────────────────┤ │
+│ │ 👤 Julie (Staff) [Edit] [🗑️] │ │
+│ │ Last used: Yesterday │ │
+│ │ Status: 🔒 Locked (3 failed attempts) [Unlock] │ │
+│ └─────────────────────────────────────────────────────────┘ │
+│ │
+│ ℹ️ Staff PINs prevent unauthorized stamp/point operations. │
+│ PINs are locked after 5 failed attempts for 30 minutes. │
+│ │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+#### Customer Cards List (`/store/loyalty/cards`)
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ 👥 Loyalty Members 🔍 [Search...] [Export]│
+├─────────────────────────────────────────────────────────────────┤
+│ │
+│ Filter: [All ▼] [Active ▼] [Has Reward Ready ▼] │
+│ │
+│ ┌─────────────────────────────────────────────────────────────┐│
+│ │ Customer │ Card # │ Stamps │ Last Visit │ ⋮ ││
+│ ├───────────────────┼──────────────┼────────┼────────────┼────┤│
+│ │ Marie Laurent │ 4821-7493 │ 8/10 ⭐│ Today │ ⋮ ││
+│ │ Jean Dupont │ 4821-2847 │ 10/10 🎁│ Yesterday │ ⋮ ││
+│ │ Sophie Martin │ 4821-9382 │ 3/10 │ 3 days ago │ ⋮ ││
+│ │ Pierre Bernard │ 4821-1029 │ 6/10 │ 1 week ago │ ⋮ ││
+│ │ ... │ ... │ ... │ ... │ ⋮ ││
+│ └─────────────────────────────────────────────────────────────┘│
+│ │
+│ Showing 1-20 of 1,247 members [← Prev] [1] [2] [Next →]│
+│ │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+### 1.2 Admin Dashboard (Platform)
+
+#### Platform Loyalty Overview (`/admin/loyalty`)
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ 🎯 Loyalty Programs Platform │
+├─────────────────────────────────────────────────────────────────┤
+│ │
+│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────┐│
+│ │ 47 │ │ 38 │ │ 12,847 │ │ €47k ││
+│ │ Programs │ │ Active │ │ Members │ │ Saved ││
+│ │ Total │ │ Programs │ │ Total │ │ Value ││
+│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────┘│
+│ │
+│ Programs by Type: │
+│ ═══════════════════════════════════════ │
+│ Stamps: ████████████████████ 32 (68%) │
+│ Points: ███████ 11 (23%) │
+│ Hybrid: ████ 4 (9%) │
+│ │
+│ ┌─────────────────────────────────────────────────────────────┐│
+│ │ Store │ Type │ Members │ Activity │ Status ││
+│ ├───────────────────┼─────────┼─────────┼──────────┼──────────┤│
+│ │ Café du Coin │ Stamps │ 1,247 │ High │ ✅ Active││
+│ │ Boulangerie Paul │ Points │ 892 │ Medium │ ✅ Active││
+│ │ Pizza Roma │ Stamps │ 456 │ Low │ ⚠️ Setup ││
+│ │ ... │ ... │ ... │ ... │ ... ││
+│ └─────────────────────────────────────────────────────────────┘│
+│ │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+---
+
+## Part 2: User Journeys
+
+### 2.1 Stamp-Based Loyalty Journey
+
+#### Customer Journey: Enrollment
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ STAMP LOYALTY - ENROLLMENT │
+└─────────────────────────────────────────────────────────────────┘
+
+ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
+ │ DISCOVER│────▶│ JOIN │────▶│ SAVE │────▶│ USE │
+ └─────────┘ └─────────┘ └─────────┘ └─────────┘
+ │ │ │ │
+ ▼ ▼ ▼ ▼
+ ┌─────────────────────────────────────────────────────────┐
+ │ 1. Customer sees │ 2. Scans QR at │ 3. Card added │ 4. Ready to │
+ │ sign at counter│ register or │ to Google/ │ collect │
+ │ "Join our │ gives email │ Apple Wallet│ stamps! │
+ │ loyalty!" │ to cashier │ │ │
+ └─────────────────────────────────────────────────────────┘
+```
+
+**Detailed Steps:**
+
+1. **Discovery** (In-Store)
+ - Customer sees loyalty program signage/tent card
+ - QR code displayed at counter
+ - Staff mentions program during checkout
+
+2. **Sign Up** (30 seconds)
+ - Customer scans QR code with phone
+ - Lands on mobile enrollment page
+ - Enters: Email (required), Name (optional)
+ - Accepts terms with checkbox
+ - Submits
+
+3. **Card Creation** (Instant)
+ - System creates loyalty card
+ - Generates unique card number & QR code
+ - Shows "Add to Wallet" buttons
+ - Sends welcome email with card link
+
+4. **Wallet Save** (Optional but encouraged)
+ - Customer taps "Add to Google Wallet" or "Add to Apple Wallet"
+ - Pass appears in their wallet app
+ - Always accessible, works offline
+
+#### Customer Journey: Earning Stamps
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ STAMP LOYALTY - EARNING │
+└─────────────────────────────────────────────────────────────────┘
+
+ Customer Staff System Wallet
+ │ │ │ │
+ │ 1. Makes │ │ │
+ │ purchase │ │ │
+ │───────────────▶│ │ │
+ │ │ │ │
+ │ 2. Shows │ │ │
+ │ loyalty card │ │ │
+ │───────────────▶│ │ │
+ │ │ 3. Scans QR │ │
+ │ │─────────────────▶│ │
+ │ │ │ │
+ │ │ 4. Enters PIN │ │
+ │ │─────────────────▶│ │
+ │ │ │ │
+ │ │ 5. Confirms │ │
+ │ │◀─────────────────│ │
+ │ │ "Stamp added!" │ │
+ │ │ │ │
+ │ 6. Verbal │ │ 7. Push │
+ │ confirmation │ │ notification │
+ │◀───────────────│ │────────────────▶│
+ │ │ │ │
+ │ │ 8. Pass updates│
+ │◀───────────────────────────────────│────────────────▶│
+ │ "8/10 stamps" │ │
+```
+
+**Anti-Fraud Checks (Automatic):**
+
+1. ✅ Card is active
+2. ✅ Program is active
+3. ✅ Staff PIN is valid
+4. ✅ Cooldown period elapsed (15 min since last stamp)
+5. ✅ Daily limit not reached (max 5/day)
+
+**Success Response:**
+```json
+{
+ "success": true,
+ "stamp_count": 8,
+ "stamps_target": 10,
+ "stamps_until_reward": 2,
+ "message": "2 more stamps until your free coffee!",
+ "next_stamp_available": "2024-01-28T15:30:00Z"
+}
+```
+
+#### Customer Journey: Redeeming Reward
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ STAMP LOYALTY - REDEMPTION │
+└─────────────────────────────────────────────────────────────────┘
+
+ Customer Staff System
+ │ │ │
+ │ 1. "I'd like │ │
+ │ to redeem my │ │
+ │ free coffee" │ │
+ │───────────────▶│ │
+ │ │ │
+ │ 2. Shows card │ │
+ │ (10/10 stamps)│ │
+ │───────────────▶│ │
+ │ │ 3. Scans + sees │
+ │ │ "REWARD READY" │
+ │ │─────────────────▶│
+ │ │ │
+ │ │ 4. Clicks │
+ │ │ [REDEEM REWARD] │
+ │ │─────────────────▶│
+ │ │ │
+ │ │ 5. Enters PIN │
+ │ │─────────────────▶│
+ │ │ │
+ │ │ 6. Confirms │
+ │ │◀─────────────────│
+ │ │ "Reward redeemed"│
+ │ │ Stamps reset: 0 │
+ │ │ │
+ │ 7. Gives free │ │
+ │ coffee │ │
+ │◀───────────────│ │
+ │ │ │
+ │ 🎉 HAPPY │ │
+ │ CUSTOMER! │ │
+```
+
+### 2.2 Points-Based Loyalty Journey
+
+#### Customer Journey: Earning Points
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ POINTS LOYALTY - EARNING │
+└─────────────────────────────────────────────────────────────────┘
+
+ Customer Staff System
+ │ │ │
+ │ 1. Purchases │ │
+ │ €25.00 order │ │
+ │───────────────▶│ │
+ │ │ │
+ │ 2. Shows │ │
+ │ loyalty card │ │
+ │───────────────▶│ │
+ │ │ 3. Scans card │
+ │ │─────────────────▶│
+ │ │ │
+ │ │ 4. Enters amount │
+ │ │ €25.00 │
+ │ │─────────────────▶│
+ │ │ │
+ │ │ 5. Enters PIN │
+ │ │─────────────────▶│
+ │ │ │ ┌──────────┐
+ │ │ │ │Calculate:│
+ │ │ │ │€25 × 10 │
+ │ │ │ │= 250 pts │
+ │ │ │ └──────────┘
+ │ │ 6. Confirms │
+ │ │◀─────────────────│
+ │ │ "+250 points!" │
+ │ │ │
+ │ 7. Receipt │ │
+ │ shows points │ │
+ │◀───────────────│ │
+```
+
+**Points Calculation:**
+```
+Purchase: €25.00
+Rate: 10 points per euro
+Points Earned: 250 points
+New Balance: 750 points
+```
+
+#### Customer Journey: Redeeming Points
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ POINTS LOYALTY - REDEMPTION │
+└─────────────────────────────────────────────────────────────────┘
+
+ Customer Staff System
+ │ │ │
+ │ 1. Views │ │
+ │ rewards in │ │
+ │ wallet app │ │
+ │ │ │ │
+ │ ▼ │ │
+ │ ┌──────────┐ │ │
+ │ │ REWARDS │ │ │
+ │ │──────────│ │ │
+ │ │ 500 pts │ │ │
+ │ │ Free │ │ │
+ │ │ Drink │ │ │
+ │ │──────────│ │ │
+ │ │ 1000 pts │ │ │
+ │ │ Free │ │ │
+ │ │ Meal │ │ │
+ │ └──────────┘ │ │
+ │ │ │
+ │ 2. "I want to │ │
+ │ redeem for │ │
+ │ free drink" │ │
+ │───────────────▶│ │
+ │ │ 3. Scans card │
+ │ │ Selects reward │
+ │ │─────────────────▶│
+ │ │ │
+ │ │ 4. Enters PIN │
+ │ │─────────────────▶│
+ │ │ │
+ │ │ 5. Confirms │
+ │ │◀─────────────────│
+ │ │ "-500 points" │
+ │ │ Balance: 250 pts │
+ │ │ │
+ │ 6. Gets free │ │
+ │ drink │ │
+ │◀───────────────│ │
+```
+
+---
+
+## Part 3: Market Best Practices
+
+### 3.1 Competitive Analysis
+
+| Feature | Square Loyalty | Toast | Fivestars | **Orion** |
+|---------|---------------|-------|-----------|--------------|
+| Stamp cards | ✅ | ✅ | ✅ | ✅ |
+| Points system | ✅ | ✅ | ✅ | ✅ |
+| Google Wallet | ✅ | ❌ | ✅ | ✅ |
+| Apple Wallet | ✅ | ✅ | ✅ | ✅ |
+| Staff PIN | ❌ | ✅ | ✅ | ✅ |
+| Cooldown fraud protection | ❌ | ❌ | ✅ | ✅ |
+| Daily limits | ❌ | ❌ | ✅ | ✅ |
+| Tablet terminal | ✅ | ✅ | ✅ | ✅ (planned) |
+| Customer app | ✅ | ✅ | ✅ | Via Wallet |
+| Analytics dashboard | ✅ | ✅ | ✅ | ✅ |
+
+### 3.2 Best Practices to Implement
+
+#### UX Best Practices
+
+1. **Instant gratification** - Show stamp/points immediately after transaction
+2. **Progress visualization** - Clear progress bars/stamp grids
+3. **Reward proximity** - "Only 2 more until your free coffee!"
+4. **Wallet-first** - Push customers to save to wallet
+5. **Offline support** - Card works even without internet (via wallet)
+
+#### Fraud Prevention Best Practices
+
+1. **Multi-layer security** - PIN + cooldown + daily limits
+2. **Staff accountability** - Every transaction tied to a staff PIN
+3. **Audit trail** - Complete history with IP/device info
+4. **Lockout protection** - Automatic PIN lockout after failures
+5. **Admin oversight** - Unlock and PIN management in dashboard
+
+#### Engagement Best Practices
+
+1. **Welcome bonus** - Give 1 stamp on enrollment (configurable)
+2. **Birthday rewards** - Extra stamps/points on customer birthday
+3. **Milestone notifications** - "Congrats! 50 stamps earned lifetime!"
+4. **Re-engagement** - Remind inactive customers via email
+5. **Double points days** - Promotional multipliers (future)
+
+---
+
+## Part 4: Implementation Roadmap
+
+### Phase 2A: Store Interface (Priority)
+
+| Task | Effort | Priority |
+|------|--------|----------|
+| Loyalty terminal (scan/stamp/redeem) | 3 days | P0 |
+| Program setup wizard | 2 days | P0 |
+| Staff PIN management | 1 day | P0 |
+| Customer cards list | 1 day | P1 |
+| Dashboard with stats | 2 days | P1 |
+| Export functionality | 1 day | P2 |
+
+### Phase 2B: Admin Interface
+
+| Task | Effort | Priority |
+|------|--------|----------|
+| Programs list view | 1 day | P1 |
+| Platform-wide stats | 1 day | P1 |
+| Program detail view | 0.5 day | P2 |
+
+### Phase 2C: Customer Experience
+
+| Task | Effort | Priority |
+|------|--------|----------|
+| Enrollment page (mobile) | 1 day | P0 |
+| Card detail page | 0.5 day | P1 |
+| Wallet pass polish | 1 day | P1 |
+| Email templates | 1 day | P2 |
+
+### Phase 2D: Polish & Advanced
+
+| Task | Effort | Priority |
+|------|--------|----------|
+| QR code scanner (JS) | 2 days | P0 |
+| Real-time updates (WebSocket) | 1 day | P2 |
+| Receipt printing | 1 day | P3 |
+| POS integration hooks | 2 days | P3 |
+
+---
+
+## Part 5: Technical Specifications
+
+### Store Terminal Requirements
+
+- **Responsive**: Works on tablet (primary), desktop, mobile
+- **Touch-friendly**: Large buttons, numpad for PIN
+- **Camera access**: For QR code scanning (WebRTC)
+- **Offline-capable**: Queue operations if network down (future)
+- **Real-time**: WebSocket for instant updates
+
+### Frontend Stack
+
+- **Framework**: React/Vue components (match existing stack)
+- **QR Scanner**: `html5-qrcode` or `@aspect-sdk/barcode-reader`
+- **Charts**: Existing charting library (Chart.js or similar)
+- **Animations**: CSS transitions for stamp animations
+
+### API Considerations
+
+- All store endpoints require `store_id` from auth token
+- Staff PIN passed in request body, not headers
+- Rate limiting on lookup/scan endpoints
+- Pagination on card list (default 50)
+
+---
+
+## Appendix: Mockup Reference Images
+
+### Stamp Card Visual (Wallet Pass)
+
+```
+┌────────────────────────────────────┐
+│ ☕ Café du Coin │
+│ │
+│ ████ ████ ████ ████ ████ │
+│ ████ ████ ████ ░░░░ ░░░░ │
+│ │
+│ 8/10 STAMPS │
+│ 2 more until FREE COFFEE │
+│ │
+│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │
+│ ▓▓▓▓▓▓▓▓ QR CODE ▓▓▓▓▓▓▓▓ │
+│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │
+│ │
+│ Card #4821-7493-2841 │
+└────────────────────────────────────┘
+```
+
+### Points Card Visual (Wallet Pass)
+
+```
+┌────────────────────────────────────┐
+│ 🍕 Pizza Roma Rewards │
+│ │
+│ ★ 750 ★ │
+│ POINTS │
+│ │
+│ ────────────────────── │
+│ Next reward: 500 pts │
+│ Free drink │
+│ ────────────────────── │
+│ │
+│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │
+│ ▓▓▓▓▓▓▓▓ QR CODE ▓▓▓▓▓▓▓▓ │
+│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │
+│ │
+│ Card #4821-2847-9283 │
+└────────────────────────────────────┘
+```
+
+---
+
+*Document Version: 1.0*
+*Created: 2025-01-28*
+*Author: Orion Engineering*
diff --git a/app/modules/loyalty/docs/user-journeys.md b/app/modules/loyalty/docs/user-journeys.md
new file mode 100644
index 00000000..4dd75478
--- /dev/null
+++ b/app/modules/loyalty/docs/user-journeys.md
@@ -0,0 +1,794 @@
+# 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.
diff --git a/app/modules/marketplace/docs/admin-guide.md b/app/modules/marketplace/docs/admin-guide.md
new file mode 100644
index 00000000..efd0a19a
--- /dev/null
+++ b/app/modules/marketplace/docs/admin-guide.md
@@ -0,0 +1,261 @@
+# Letzshop Admin Management Guide
+
+Complete guide for managing Letzshop integration from the Admin Portal at `/admin/marketplace/letzshop`.
+
+## Table of Contents
+
+- [Overview](#overview)
+- [Store Selection](#store-selection)
+- [Products Tab](#products-tab)
+- [Orders Tab](#orders-tab)
+- [Exceptions Tab](#exceptions-tab)
+- [Jobs Tab](#jobs-tab)
+- [Settings Tab](#settings-tab)
+
+---
+
+## Overview
+
+The Letzshop Management page provides a unified interface for managing Letzshop marketplace integration for all stores. Key features:
+
+- **Multi-Store Support**: Select any store to manage their Letzshop integration
+- **Product Management**: View, import, and export products
+- **Order Processing**: View orders, confirm inventory, set tracking
+- **Exception Handling**: Resolve product matching exceptions
+- **Job Monitoring**: Track import, export, and sync operations
+- **Configuration**: Manage CSV URLs, credentials, and sync settings
+
+---
+
+## Store Selection
+
+At the top of the page, use the store autocomplete to select which store to manage:
+
+1. Type to search for a store by name or code
+2. Select from the dropdown
+3. The page loads store-specific data for all tabs
+4. Your selection is saved and restored on next visit
+
+**Cross-Store View**: When no store is selected, the Orders and Exceptions tabs show data across all stores.
+
+---
+
+## Products Tab
+
+The Products tab displays Letzshop marketplace products imported for the selected store.
+
+### Product Listing
+
+- **Search**: Filter by title, GTIN, SKU, or brand
+- **Status Filter**: Show all, active only, or inactive only
+- **Pagination**: Navigate through product pages
+
+### Product Table Columns
+
+| Column | Description |
+|--------|-------------|
+| Product | Image, title, and brand |
+| Identifiers | GTIN and SKU codes |
+| Price | Product price with currency |
+| Status | Active/Inactive badge |
+| Actions | View product details |
+
+### Import Products
+
+Click the **Import** button to open the import modal:
+
+1. **Import Single Language**: Select a language and enter the CSV URL
+2. **Import All Languages**: Imports from all configured CSV URLs (FR, DE, EN)
+
+Import settings (batch size) are configured in the Settings tab.
+
+### Export Products
+
+Click the **Export** button to export products to the Letzshop pickup folder:
+
+- Exports all three languages (FR, DE, EN) automatically
+- Files are placed in `exports/letzshop/{store_code}/`
+- Filename format: `{store_code}_products_{language}.csv`
+- The export is logged and appears in the Jobs tab
+
+Export settings (include inactive products) are configured in the Settings tab.
+
+---
+
+## Orders Tab
+
+The Orders tab displays orders from Letzshop for the selected store (or all stores if none selected).
+
+### Order Listing
+
+- **Search**: Filter by order number, customer name, or email
+- **Status Filter**: All, Pending, Confirmed, Shipped, Declined
+- **Date Range**: Filter by order date
+
+### Order Actions
+
+| Action | Description |
+|--------|-------------|
+| View | Open order details modal |
+| Confirm | Confirm all items in order |
+| Decline | Decline all items in order |
+| Set Tracking | Add tracking number and carrier |
+
+### Order Details Modal
+
+Shows complete order information including:
+
+- Order number and date
+- Customer name and email
+- Shipping address
+- Order items with confirmation status
+- Tracking information (if set)
+
+---
+
+## Exceptions Tab
+
+The Exceptions tab shows product matching exceptions that need resolution. See the [Order Item Exceptions documentation](../orders/exceptions.md) for details.
+
+### Exception Types
+
+When an order is imported and a product cannot be matched by GTIN:
+
+1. The order is imported with a placeholder product
+2. An exception is created for resolution
+3. The order cannot be confirmed until exceptions are resolved
+
+### Resolution Actions
+
+| Action | Description |
+|--------|-------------|
+| Resolve | Assign the correct product to the order item |
+| Bulk Resolve | Resolve all exceptions for the same GTIN |
+| Ignore | Mark as ignored (still blocks confirmation) |
+
+---
+
+## Jobs Tab
+
+The Jobs tab provides a unified view of all Letzshop-related operations for the selected store.
+
+### Job Types
+
+| Type | Icon | Color | Description |
+|------|------|-------|-------------|
+| Product Import | Cloud Download | Purple | Importing products from Letzshop CSV |
+| Product Export | Cloud Upload | Blue | Exporting products to pickup folder |
+| Historical Import | Clock | Orange | Importing historical orders |
+| Order Sync | Refresh | Indigo | Syncing orders from Letzshop API |
+
+### Job Information
+
+Each job displays:
+
+- **ID**: Unique job identifier
+- **Type**: Import, Export, Historical Import, or Order Sync
+- **Status**: Pending, Processing, Completed, Failed, or Partial
+- **Records**: Success count / Total processed (failed count if any)
+- **Started**: When the job began
+- **Duration**: How long the job took
+
+#### Records Column Meaning
+
+| Job Type | Records Shows |
+|----------|---------------|
+| Product Import | Products imported / Total products |
+| Product Export | Files exported / Total files (3 languages) |
+| Historical Import | Orders imported / Total orders |
+| Order Sync | Orders synced / Total orders |
+
+### Filtering
+
+- **Type Filter**: Show specific job types
+- **Status Filter**: Show jobs with specific status
+
+### Job Actions
+
+| Action | Description |
+|--------|-------------|
+| View Errors | Show error details (for failed jobs) |
+| View Details | Show complete job information |
+
+---
+
+## Settings Tab
+
+The Settings tab manages Letzshop integration configuration for the selected store.
+
+### CSV Feed URLs
+
+Configure the URLs for Letzshop product CSV feeds:
+
+- **French (FR)**: URL for French product data
+- **German (DE)**: URL for German product data
+- **English (EN)**: URL for English product data
+
+### Import Settings
+
+- **Batch Size**: Number of products to process per batch (100-5000)
+
+### Export Settings
+
+- **Include Inactive**: Whether to include inactive products in exports
+
+### API Credentials
+
+Configure Letzshop API access:
+
+- **API Key**: Your Letzshop API key (encrypted at rest)
+- **Test Connection**: Verify API connectivity
+
+### Sync Settings
+
+- **Auto-Sync Enabled**: Enable automatic order synchronization
+- **Sync Interval**: How often to sync orders (in minutes)
+
+---
+
+## API Endpoints
+
+### Products
+
+| Endpoint | Method | Description |
+|----------|--------|-------------|
+| `/admin/products` | GET | List marketplace products with filters |
+| `/admin/products/stats` | GET | Get product statistics |
+| `/admin/letzshop/stores/{id}/export` | GET | Download CSV export |
+| `/admin/letzshop/stores/{id}/export` | POST | Export to pickup folder |
+
+### Jobs
+
+| Endpoint | Method | Description |
+|----------|--------|-------------|
+| `/admin/letzshop/stores/{id}/jobs` | GET | List jobs for store |
+| `/admin/marketplace-import-jobs` | POST | Create import job |
+
+### Orders
+
+See [Letzshop Order Integration](order-integration.md) for complete order API documentation.
+
+---
+
+## Best Practices
+
+### Product Management
+
+1. **Regular Imports**: Schedule regular imports to keep product data current
+2. **Export Before Sync**: Export products before Letzshop's pickup schedule
+3. **Monitor Jobs**: Check the Jobs tab for failed imports/exports
+
+### Order Processing
+
+1. **Check Exceptions First**: Resolve exceptions before confirming orders
+2. **Verify Tracking**: Ensure tracking numbers are valid before submission
+3. **Monitor Sync Status**: Check for failed order syncs in Jobs tab
+
+### Troubleshooting
+
+1. **Products Not Appearing**: Verify CSV URL is accessible and valid
+2. **Export Failed**: Check write permissions on exports directory
+3. **Orders Not Syncing**: Verify API credentials and test connection
diff --git a/app/modules/marketplace/docs/api.md b/app/modules/marketplace/docs/api.md
new file mode 100644
index 00000000..cde0a59c
--- /dev/null
+++ b/app/modules/marketplace/docs/api.md
@@ -0,0 +1,322 @@
+# Letzshop Marketplace Integration
+
+## Introduction
+
+This guide covers the Orion platform's integration with the Letzshop marketplace, including:
+
+- **Product Export**: Generate Letzshop-compatible CSV files from your product catalog
+- **Order Import**: Fetch and manage orders from Letzshop
+- **Fulfillment Sync**: Confirm/reject orders, set tracking numbers
+- **GraphQL API Reference**: Direct API access for advanced use cases
+
+---
+
+## Product Export
+
+### Overview
+
+The Orion platform can export your products to Letzshop-compatible CSV format (Google Shopping feed format). This allows you to:
+
+- Upload your product catalog to Letzshop marketplace
+- Generate feeds in multiple languages (English, French, German)
+- Include all required Letzshop fields automatically
+
+### API Endpoints
+
+#### Store Export
+
+```http
+GET /api/v1/store/letzshop/export?language=fr
+Authorization: Bearer
+```
+
+**Parameters:**
+
+| Parameter | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `language` | string | `en` | Language for title/description (`en`, `fr`, `de`) |
+| `include_inactive` | bool | `false` | Include inactive products |
+
+**Response:** CSV file download (`store_code_letzshop_export.csv`)
+
+#### Admin Export
+
+```http
+GET /api/v1/admin/letzshop/export?store_id=1&language=fr
+Authorization: Bearer
+```
+
+**Additional Parameters:**
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `store_id` | int | Required. Store ID to export |
+
+### CSV Format
+
+The export generates a tab-separated CSV file with these columns:
+
+| Column | Description | Example |
+|--------|-------------|---------|
+| `id` | Product SKU | `PROD-001` |
+| `title` | Product title (localized) | `Wireless Headphones` |
+| `description` | Product description (localized) | `High-quality...` |
+| `link` | Product URL | `https://shop.example.com/product/123` |
+| `image_link` | Main product image | `https://cdn.example.com/img.jpg` |
+| `additional_image_link` | Additional images (comma-separated) | `img2.jpg,img3.jpg` |
+| `availability` | Stock status | `in stock` / `out of stock` |
+| `price` | Regular price with currency | `49.99 EUR` |
+| `sale_price` | Sale price with currency | `39.99 EUR` |
+| `brand` | Brand name | `TechBrand` |
+| `gtin` | Global Trade Item Number | `0012345678901` |
+| `mpn` | Manufacturer Part Number | `TB-WH-001` |
+| `google_product_category` | Google category ID | `Electronics > Audio` |
+| `condition` | Product condition | `new` / `used` / `refurbished` |
+| `atalanda:tax_rate` | Luxembourg VAT rate | `17` |
+
+### Multi-Language Support
+
+Products are exported with localized content based on the `language` parameter:
+
+```bash
+# French export
+curl -H "Authorization: Bearer $TOKEN" \
+ "https://api.example.com/api/v1/store/letzshop/export?language=fr"
+
+# German export
+curl -H "Authorization: Bearer $TOKEN" \
+ "https://api.example.com/api/v1/store/letzshop/export?language=de"
+
+# English export (default)
+curl -H "Authorization: Bearer $TOKEN" \
+ "https://api.example.com/api/v1/store/letzshop/export?language=en"
+```
+
+If a translation is not available for the requested language, the system falls back to English, then to any available translation.
+
+### Using the Export
+
+1. **Navigate to Letzshop** in your store dashboard
+2. **Click the Export tab**
+3. **Select your language** (French, German, or English)
+4. **Click "Download CSV"**
+5. **Upload to Letzshop** via their merchant portal
+
+---
+
+## Order Integration
+
+For details on order import and fulfillment, see [Letzshop Order Integration](order-integration.md).
+
+---
+
+## Letzshop GraphQL API Reference
+
+The following sections document the Letzshop GraphQL API for direct integration.
+
+---
+
+## GraphQL API
+
+Utilizing this API, you can retrieve and modify data on products, stores, and shipments. Letzshop uses GraphQL, allowing for precise queries.
+
+**Endpoint**:
+http://letzshop.lu/graphql
+Replace YOUR_API_ACCESS_KEY with your actual key or remove the Authorization header for public data.
+
+## Authentication
+
+Some data is public (e.g., store description and product prices).
+For protected operations (e.g., orders or vouchers), an API key is required.
+
+Request one via your account manager or email: support@letzshop.lu
+
+
+Include the key:
+
+
+Authorization: Bearer YOUR_API_ACCESS_KEY
+
+---
+
+## Playground
+
+- Access the interactive GraphQL Playground via the Letzshop website.
+- It provides live docs, auto-complete (CTRL + Space), and run-time testing.
+- **Caution**: You're working on production—mutations like confirming orders are real. [1](https://letzshop.lu/en/dev)
+
+---
+
+## Deprecations
+
+The following GraphQL fields will be removed soon:
+
+| Type | Field | Replacement |
+|---------|---------------------|----------------------------------|
+| Event | latitude, longitude | `#lat`, `#lng` |
+| Greco | weight | `packages.weight` |
+| Product | ageRestriction | `_age_restriction` (int) |
+| Taxon | identifier | `slug` |
+| User | billAddress, shipAddress | on `orders` instead |
+| Store | facebookLink, instagramLink, twitterLink, youtubeLink | `social_media_links` |
+| Store | owner | `representative` |
+| Store | permalink | `slug` | [1](https://letzshop.lu/en/dev)
+
+---
+
+## Order Management via API
+
+Using the API, you can:
+
+- Fetch unconfirmed orders
+- Confirm or reject them
+- Set tracking numbers
+- Handle returns
+
+All of this requires at least "shop manager" API key access. Multi-store management is supported if rights allow. [1](https://letzshop.lu/en/dev)
+
+### 1. Retrieve Unconfirmed Shipments
+
+**Query:**
+```graphql
+query {
+ shipments(state: unconfirmed) {
+ nodes {
+ id
+ inventoryUnits {
+ id
+ state
+ }
+ }
+ }
+}
+``` [1](https://letzshop.lu/en/dev)
+
+---
+
+### 2. Confirm/Reject Inventory Units
+
+Use inventoryUnit IDs to confirm or reject:
+
+```graphql
+mutation {
+ confirmInventoryUnits(input: {
+ inventoryUnits: [
+ { inventoryUnitId: "ID1", isAvailable: true },
+ { inventoryUnitId: "ID2", isAvailable: false },
+ { inventoryUnitId: "ID3", isAvailable: false }
+ ]
+ }) {
+ inventoryUnits {
+ id
+ state
+ }
+ errors {
+ id
+ code
+ message
+ }
+ }
+}
+``` [1](https://letzshop.lu/en/dev)
+
+---
+
+### 3. Handle Customer Returns
+
+Use only after receiving returned items:
+
+```graphql
+mutation {
+ returnInventoryUnits(input: {
+ inventoryUnits: [
+ { inventoryUnitId: "ID1" },
+ { inventoryUnitId: "ID2" }
+ ]
+ }) {
+ inventoryUnits {
+ id
+ state
+ }
+ errors {
+ id
+ code
+ }
+ }
+}
+``` [1](https://letzshop.lu/en/dev)
+
+---
+
+### 4. Set Shipment Tracking Number
+
+Include shipping provider and tracking code:
+
+```graphql
+mutation {
+ setShipmentTracking(input: {
+ shipmentId: "SHIPMENT_ID",
+ code: "TRACK123",
+ provider: THE_SHIPPING_PROVIDER
+ }) {
+ shipment {
+ tracking {
+ code
+ provider
+ }
+ }
+ errors {
+ code
+ message
+ }
+ }
+}
+``` [1](https://letzshop.lu/en/dev)
+
+---
+
+## Event System
+
+Subscribe by contacting support@letzshop.lu. Events are delivered via an SNS-like message structure:
+
+```json
+{
+ "Type": "Notification",
+ "MessageId": "XXX",
+ "TopicArn": "arn:aws:sns:eu-central-1:XXX:events",
+ "Message": "{\"id\":\"XXX\",\"type\":\"XXX\",\"payload\":{...}}",
+ "Timestamp": "2019-01-01T00:00:00.000Z",
+ "SignatureVersion": "1",
+ "Signature": "XXX",
+ "SigningCertURL": "...",
+ "UnsubscribeURL": "..."
+}
+``` [1](https://letzshop.lu/en/dev)
+
+### Message Payload
+
+Each event includes:
+
+- `id`
+- `type` (e.g., ShipmentConfirmed, UserCreated…)
+- `payload` (object-specific data) [1](https://letzshop.lu/en/dev)
+
+---
+
+## Event Types & Payload Structure
+
+A variety of event types are supported. Common ones include:
+
+- `ShipmentConfirmed`, `ShipmentRefundCreated`
+- `UserAnonymized`, `UserCreated`, `UserDestroyed`, `UserUpdated`
+- `VariantWithPriceCrossedCreated`, `...Updated`
+- `StoreCategoryCreated`, `Destroyed`, `Updated`
+- `StoreCreated`, `Destroyed`, `Updated`
+
+Exact payload structure varies per event type. [1](https://letzshop.lu/en/dev)
+
+---
+
+## Conclusion
+
+This Markdown file captures all information from the Letzshop development page, formatted for use in your documentation or GitHub.
diff --git a/app/modules/marketplace/docs/architecture.md b/app/modules/marketplace/docs/architecture.md
new file mode 100644
index 00000000..ebfc4c76
--- /dev/null
+++ b/app/modules/marketplace/docs/architecture.md
@@ -0,0 +1,1345 @@
+# Multi-Marketplace Integration Architecture
+
+## Executive Summary
+
+This document defines the complete architecture for integrating Orion with multiple external marketplaces (Letzshop, Amazon, eBay) and digital product suppliers (CodesWholesale). The integration is **bidirectional**, supporting both inbound flows (products, orders) and outbound flows (inventory sync, fulfillment status).
+
+**Key Capabilities:**
+
+| Capability | Description |
+|------------|-------------|
+| **Multi-Marketplace Products** | Import and normalize products from multiple sources |
+| **Multi-Language Support** | Translations with language fallback |
+| **Unified Order Management** | Orders from all channels in one place |
+| **Digital Product Fulfillment** | On-demand license key retrieval from suppliers |
+| **Inventory Sync** | Real-time and scheduled stock updates to marketplaces |
+| **Fulfillment Sync** | Order status and tracking back to marketplaces |
+
+---
+
+## System Overview
+
+```mermaid
+graph TB
+ subgraph "External Systems"
+ LS[Letzshop CSV + GraphQL]
+ AZ[Amazon API]
+ EB[eBay API]
+ CW[CodesWholesale Digital Supplier API]
+ WS[Store Storefront Orion Shop]
+ end
+
+ subgraph "Integration Layer"
+ subgraph "Inbound Adapters"
+ PI[Product Importers]
+ OI[Order Importers]
+ DPI[Digital Product Importer]
+ end
+ subgraph "Outbound Adapters"
+ IS[Inventory Sync]
+ FS[Fulfillment Sync]
+ PE[Product Export]
+ end
+ end
+
+ subgraph "Orion Core"
+ MP[Marketplace Products]
+ P[Store Products]
+ O[Unified Orders]
+ I[Inventory]
+ F[Fulfillment]
+ DL[Digital License Pool]
+ end
+
+ LS -->|CSV Import| PI
+ AZ -->|API Pull| PI
+ EB -->|API Pull| PI
+ CW -->|Catalog Sync| DPI
+
+ LS -->|GraphQL Poll| OI
+ AZ -->|API Poll| OI
+ EB -->|API Poll| OI
+ WS -->|Direct| O
+
+ PI --> MP
+ DPI --> MP
+ MP --> P
+ OI --> O
+
+ I -->|Real-time/Scheduled| IS
+ F -->|Status Update| FS
+ P -->|CSV Export| PE
+
+ IS --> LS
+ IS --> AZ
+ IS --> EB
+ FS --> LS
+ FS --> AZ
+ FS --> EB
+ PE --> LS
+
+ CW -->|On-demand Keys| DL
+ DL --> F
+```
+
+---
+
+## Integration Phases
+
+| Phase | Scope | Priority | Dependencies |
+|-------|-------|----------|--------------|
+| **Phase 1** | Product Import | High | None |
+| **Phase 2** | Order Import | High | Phase 1 |
+| **Phase 3** | Order Fulfillment Sync | High | Phase 2 |
+| **Phase 4** | Inventory Sync | Medium | Phase 1 |
+
+---
+
+## Marketplace Capabilities Matrix
+
+| Marketplace | Products In | Products Out | Orders In | Fulfillment Out | Inventory Out | Method |
+|-------------|-------------|--------------|-----------|-----------------|---------------|--------|
+| **Letzshop** | CSV Import | CSV Export | GraphQL Poll | GraphQL | CSV/GraphQL | File + API |
+| **Amazon** | API | N/A | API Poll | API | API (Real-time) | API |
+| **eBay** | API | N/A | API Poll | API | API (Real-time) | API |
+| **CodesWholesale** | API Catalog | N/A | N/A | On-demand Keys | N/A | API |
+| **Store Storefront** | N/A | N/A | Direct DB | Internal | Internal | Direct |
+
+---
+
+## Part 1: Product Integration
+
+### 1.1 Architecture Overview
+
+```mermaid
+graph TB
+ subgraph "Source Layer"
+ LS_CSV[Letzshop CSV Multi-language feeds]
+ AZ_API[Amazon API Product catalog]
+ EB_API[eBay API Product catalog]
+ CW_API[CodesWholesale API Digital catalog]
+ end
+
+ subgraph "Import Layer"
+ LSI[LetzshopImporter]
+ AZI[AmazonImporter]
+ EBI[EbayImporter]
+ CWI[CodesWholesaleImporter]
+ end
+
+ subgraph "Canonical Layer"
+ MP[(marketplace_products)]
+ MPT[(marketplace_product_translations)]
+ end
+
+ subgraph "Store Layer"
+ P[(products)]
+ PT[(product_translations)]
+ end
+
+ LS_CSV --> LSI
+ AZ_API --> AZI
+ EB_API --> EBI
+ CW_API --> CWI
+
+ LSI --> MP
+ AZI --> MP
+ EBI --> MP
+ CWI --> MP
+
+ MP --> MPT
+ MP --> P
+ P --> PT
+```
+
+### 1.2 Digital Product Supplier Integration (CodesWholesale)
+
+CodesWholesale provides digital products (game keys, gift cards, software licenses) that need special handling:
+
+```mermaid
+sequenceDiagram
+ participant CW as CodesWholesale API
+ participant Sync as Catalog Sync Job
+ participant MP as marketplace_products
+ participant P as products
+ participant LP as License Pool
+
+ Note over Sync: Scheduled catalog sync (e.g., every 6 hours)
+ Sync->>CW: GET /products (catalog)
+ CW-->>Sync: Product catalog with prices, availability
+ Sync->>MP: Upsert products (marketplace='codeswholesale')
+ Sync->>MP: Update prices, availability flags
+
+ Note over P: Store adds product to their catalog
+ P->>MP: Link to marketplace_product
+
+ Note over LP: Order placed - need license key
+ LP->>CW: POST /orders (purchase key on-demand)
+ CW-->>LP: License key / download link
+ LP->>LP: Store for fulfillment
+```
+
+**Key Characteristics:**
+
+| Aspect | Behavior |
+|--------|----------|
+| **Catalog Sync** | Scheduled job fetches full catalog, updates prices/availability |
+| **License Keys** | Purchased on-demand at fulfillment time (not pre-stocked) |
+| **Inventory** | Virtual - always "available" but subject to supplier stock |
+| **Pricing** | Dynamic - supplier prices may change, store sets markup |
+| **Regions** | Products may have region restrictions (EU, US, Global) |
+
+### 1.3 Product Data Model
+
+See [Multi-Marketplace Product Architecture](../../development/migration/multi-marketplace-product-architecture.md) for detailed schema.
+
+**Summary of tables:**
+
+| Table | Purpose |
+|-------|---------|
+| `marketplace_products` | Canonical product data from all sources |
+| `marketplace_product_translations` | Localized titles, descriptions per language |
+| `products` | Store-specific overrides and settings |
+| `product_translations` | Store-specific localized overrides |
+
+### 1.4 Import Job Flow
+
+```mermaid
+stateDiagram-v2
+ [*] --> Pending: Job Created
+ Pending --> Processing: Worker picks up
+ Processing --> Downloading: Fetch source data
+ Downloading --> Parsing: Parse rows
+ Parsing --> Upserting: Update database
+ Upserting --> Completed: All rows processed
+ Upserting --> PartiallyCompleted: Some rows failed
+ Processing --> Failed: Fatal error
+ Completed --> [*]
+ PartiallyCompleted --> [*]
+ Failed --> [*]
+```
+
+---
+
+## Part 2: Order Integration
+
+### 2.1 Unified Order Model
+
+Orders from all channels (marketplaces + store storefront) flow into a unified order management system.
+
+```mermaid
+graph TB
+ subgraph "Order Sources"
+ LS_O[Letzshop Orders GraphQL Poll]
+ AZ_O[Amazon Orders API Poll]
+ EB_O[eBay Orders API Poll]
+ VS_O[Store Storefront Direct]
+ end
+
+ subgraph "Order Import"
+ OI[Order Importer Service]
+ OQ[Order Import Queue]
+ end
+
+ subgraph "Unified Orders"
+ O[(orders)]
+ OI_T[(order_items)]
+ OS[(order_status_history)]
+ end
+
+ LS_O -->|Poll every N min| OI
+ AZ_O -->|Poll every N min| OI
+ EB_O -->|Poll every N min| OI
+ VS_O -->|Direct insert| O
+
+ OI --> OQ
+ OQ --> O
+ O --> OI_T
+ O --> OS
+```
+
+### 2.2 Order Data Model
+
+```python
+class OrderChannel(str, Enum):
+ """Order source channel."""
+ STOREFRONT = "storefront" # Store's own Orion shop
+ LETZSHOP = "letzshop"
+ AMAZON = "amazon"
+ EBAY = "ebay"
+
+class OrderStatus(str, Enum):
+ """Unified order status."""
+ PENDING = "pending" # Awaiting payment/confirmation
+ CONFIRMED = "confirmed" # Payment confirmed
+ PROCESSING = "processing" # Being prepared
+ READY_FOR_SHIPMENT = "ready_for_shipment" # Physical: packed
+ SHIPPED = "shipped" # Physical: in transit
+ DELIVERED = "delivered" # Physical: delivered
+ FULFILLED = "fulfilled" # Digital: key/download sent
+ CANCELLED = "cancelled"
+ REFUNDED = "refunded"
+ PARTIALLY_REFUNDED = "partially_refunded"
+
+class Order(Base, TimestampMixin):
+ """Unified order from all channels."""
+ __tablename__ = "orders"
+
+ id = Column(Integer, primary_key=True)
+ store_id = Column(Integer, ForeignKey("stores.id"), nullable=False)
+
+ # === CHANNEL TRACKING ===
+ channel = Column(SQLEnum(OrderChannel), nullable=False, index=True)
+ channel_order_id = Column(String, index=True) # External order ID
+ channel_order_url = Column(String) # Link to order in marketplace
+
+ # === CUSTOMER INFO ===
+ customer_id = Column(Integer, ForeignKey("customers.id"), nullable=True)
+ customer_email = Column(String, nullable=False)
+ customer_name = Column(String)
+ customer_phone = Column(String)
+
+ # === ADDRESSES ===
+ shipping_address = Column(JSON) # For physical products
+ billing_address = Column(JSON)
+
+ # === ORDER TOTALS ===
+ subtotal = Column(Float, nullable=False)
+ shipping_cost = Column(Float, default=0)
+ tax_amount = Column(Float, default=0)
+ discount_amount = Column(Float, default=0)
+ total = Column(Float, nullable=False)
+ currency = Column(String(3), default="EUR")
+
+ # === STATUS ===
+ status = Column(SQLEnum(OrderStatus), default=OrderStatus.PENDING, index=True)
+
+ # === FULFILLMENT TYPE ===
+ requires_shipping = Column(Boolean, default=True) # False for digital-only
+ is_fully_digital = Column(Boolean, default=False)
+
+ # === TIMESTAMPS ===
+ ordered_at = Column(DateTime, nullable=False) # When customer placed order
+ confirmed_at = Column(DateTime)
+ shipped_at = Column(DateTime)
+ delivered_at = Column(DateTime)
+ fulfilled_at = Column(DateTime) # For digital products
+
+ # === SYNC STATUS ===
+ last_synced_at = Column(DateTime) # Last sync with marketplace
+ sync_status = Column(String) # 'synced', 'pending', 'error'
+ sync_error = Column(Text)
+
+ # === RELATIONSHIPS ===
+ store = relationship("Store", back_populates="orders")
+ customer = relationship("Customer", back_populates="orders")
+ items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
+ status_history = relationship("OrderStatusHistory", back_populates="order")
+
+ __table_args__ = (
+ Index("idx_order_store_status", "store_id", "status"),
+ Index("idx_order_channel", "channel", "channel_order_id"),
+ Index("idx_order_store_date", "store_id", "ordered_at"),
+ )
+
+
+class OrderItem(Base, TimestampMixin):
+ """Individual item in an order."""
+ __tablename__ = "order_items"
+
+ id = Column(Integer, primary_key=True)
+ order_id = Column(Integer, ForeignKey("orders.id", ondelete="CASCADE"), nullable=False)
+ product_id = Column(Integer, ForeignKey("products.id"), nullable=True)
+
+ # === PRODUCT SNAPSHOT (at time of order) ===
+ product_name = Column(String, nullable=False)
+ product_sku = Column(String)
+ product_gtin = Column(String)
+ product_image_url = Column(String)
+
+ # === PRICING ===
+ unit_price = Column(Float, nullable=False)
+ quantity = Column(Integer, nullable=False, default=1)
+ subtotal = Column(Float, nullable=False)
+ tax_amount = Column(Float, default=0)
+ discount_amount = Column(Float, default=0)
+ total = Column(Float, nullable=False)
+
+ # === PRODUCT TYPE ===
+ is_digital = Column(Boolean, default=False)
+
+ # === DIGITAL FULFILLMENT ===
+ license_key = Column(String) # For digital products
+ download_url = Column(String)
+ download_expiry = Column(DateTime)
+ digital_fulfilled_at = Column(DateTime)
+
+ # === PHYSICAL FULFILLMENT ===
+ shipped_quantity = Column(Integer, default=0)
+
+ # === SUPPLIER TRACKING (for CodesWholesale etc) ===
+ supplier = Column(String) # 'codeswholesale', 'internal', etc.
+ supplier_order_id = Column(String) # Supplier's order reference
+ supplier_cost = Column(Float) # What we paid supplier
+
+ # === RELATIONSHIPS ===
+ order = relationship("Order", back_populates="items")
+ product = relationship("Product")
+
+
+class OrderStatusHistory(Base, TimestampMixin):
+ """Audit trail of order status changes."""
+ __tablename__ = "order_status_history"
+
+ id = Column(Integer, primary_key=True)
+ order_id = Column(Integer, ForeignKey("orders.id", ondelete="CASCADE"), nullable=False)
+
+ from_status = Column(SQLEnum(OrderStatus))
+ to_status = Column(SQLEnum(OrderStatus), nullable=False)
+ changed_by = Column(String) # 'system', 'store:123', 'marketplace:letzshop'
+ reason = Column(String)
+ metadata = Column(JSON) # Additional context (tracking number, etc.)
+
+ order = relationship("Order", back_populates="status_history")
+```
+
+### 2.3 Order Import Flow
+
+```mermaid
+sequenceDiagram
+ participant Scheduler as Scheduler
+ participant Poller as Order Poller
+ participant MP as Marketplace API
+ participant Queue as Import Queue
+ participant Worker as Import Worker
+ participant DB as Database
+ participant Notify as Notification Service
+
+ Scheduler->>Poller: Trigger poll (every N minutes)
+ Poller->>MP: Fetch orders since last_sync
+ MP-->>Poller: New/updated orders
+
+ loop For each order
+ Poller->>Queue: Enqueue order import job
+ end
+
+ Worker->>Queue: Pick up job
+ Worker->>DB: Check if order exists (by channel_order_id)
+
+ alt New Order
+ Worker->>DB: Create order + items
+ Worker->>Notify: New order notification
+ else Existing Order
+ Worker->>DB: Update order status/details
+ end
+
+ Worker->>DB: Update last_synced_at
+```
+
+### 2.4 Letzshop Order Integration (GraphQL)
+
+```python
+# Example GraphQL queries for Letzshop order integration
+
+LETZSHOP_ORDERS_QUERY = """
+query GetOrders($since: DateTime, $status: [OrderStatus!]) {
+ orders(
+ filter: {
+ updatedAt: { gte: $since }
+ status: { in: $status }
+ }
+ first: 100
+ ) {
+ edges {
+ node {
+ id
+ orderNumber
+ status
+ createdAt
+ updatedAt
+ customer {
+ email
+ firstName
+ lastName
+ phone
+ }
+ shippingAddress {
+ street
+ city
+ postalCode
+ country
+ }
+ items {
+ product {
+ id
+ sku
+ name
+ }
+ quantity
+ unitPrice
+ totalPrice
+ }
+ totals {
+ subtotal
+ shipping
+ tax
+ total
+ currency
+ }
+ }
+ }
+ pageInfo {
+ hasNextPage
+ endCursor
+ }
+ }
+}
+"""
+
+class LetzshopOrderImporter:
+ """Import orders from Letzshop via GraphQL."""
+
+ def __init__(self, store_id: int, api_url: str, api_token: str):
+ self.store_id = store_id
+ self.api_url = api_url
+ self.api_token = api_token
+
+ async def fetch_orders_since(self, since: datetime) -> list[dict]:
+ """Fetch orders updated since given timestamp."""
+ # Implementation: Execute GraphQL query
+ pass
+
+ def map_to_order(self, letzshop_order: dict) -> OrderCreate:
+ """Map Letzshop order to unified Order schema."""
+ return OrderCreate(
+ store_id=self.store_id,
+ channel=OrderChannel.LETZSHOP,
+ channel_order_id=letzshop_order["id"],
+ customer_email=letzshop_order["customer"]["email"],
+ customer_name=f"{letzshop_order['customer']['firstName']} {letzshop_order['customer']['lastName']}",
+ status=self._map_status(letzshop_order["status"]),
+ # ... map remaining fields
+ )
+
+ def _map_status(self, letzshop_status: str) -> OrderStatus:
+ """Map Letzshop status to unified status."""
+ mapping = {
+ "PENDING": OrderStatus.PENDING,
+ "PAID": OrderStatus.CONFIRMED,
+ "PROCESSING": OrderStatus.PROCESSING,
+ "SHIPPED": OrderStatus.SHIPPED,
+ "DELIVERED": OrderStatus.DELIVERED,
+ "CANCELLED": OrderStatus.CANCELLED,
+ }
+ return mapping.get(letzshop_status, OrderStatus.PENDING)
+```
+
+---
+
+## Part 3: Order Fulfillment Sync
+
+### 3.1 Fulfillment Architecture
+
+```mermaid
+graph TB
+ subgraph "Store Actions"
+ VA[Store marks order shipped]
+ VD[Store marks delivered]
+ VF[Digital fulfillment triggered]
+ end
+
+ subgraph "Fulfillment Service"
+ FS[Fulfillment Service]
+ DFS[Digital Fulfillment Service]
+ end
+
+ subgraph "Outbound Sync"
+ SQ[Sync Queue]
+ SW[Sync Workers]
+ end
+
+ subgraph "External Systems"
+ LS_API[Letzshop GraphQL]
+ AZ_API[Amazon API]
+ EB_API[eBay API]
+ CW_API[CodesWholesale API]
+ EMAIL[Email Service]
+ end
+
+ VA --> FS
+ VD --> FS
+ VF --> DFS
+
+ FS --> SQ
+ DFS --> CW_API
+ DFS --> EMAIL
+
+ SQ --> SW
+ SW --> LS_API
+ SW --> AZ_API
+ SW --> EB_API
+```
+
+### 3.2 Physical Product Fulfillment
+
+```mermaid
+sequenceDiagram
+ participant Store as Store UI
+ participant API as Fulfillment API
+ participant DB as Database
+ participant Queue as Sync Queue
+ participant Worker as Sync Worker
+ participant MP as Marketplace API
+
+ Store->>API: Mark order as shipped (tracking #)
+ API->>DB: Update order status
+ API->>DB: Add status history entry
+ API->>Queue: Enqueue fulfillment sync job
+
+ Worker->>Queue: Pick up job
+ Worker->>DB: Get order details
+ Worker->>MP: Update fulfillment status
+
+ alt Sync Success
+ MP-->>Worker: 200 OK
+ Worker->>DB: Mark sync_status='synced'
+ else Sync Failed
+ MP-->>Worker: Error
+ Worker->>DB: Mark sync_status='error', store error
+ Worker->>Queue: Retry with backoff
+ end
+```
+
+### 3.3 Digital Product Fulfillment (CodesWholesale)
+
+```mermaid
+sequenceDiagram
+ participant Order as Order Service
+ participant DFS as Digital Fulfillment Service
+ participant CW as CodesWholesale API
+ participant DB as Database
+ participant Email as Email Service
+ participant Customer as Customer
+
+ Note over Order: Order confirmed, contains digital item
+ Order->>DFS: Fulfill digital items
+
+ loop For each digital item
+ DFS->>DB: Check product supplier
+
+ alt Supplier = CodesWholesale
+ DFS->>CW: POST /orders (purchase key)
+ CW-->>DFS: License key + download info
+ DFS->>DB: Store license key on order_item
+ else Internal / Pre-loaded
+ DFS->>DB: Get key from license pool
+ DFS->>DB: Mark key as used
+ end
+ end
+
+ DFS->>DB: Update order status to FULFILLED
+ DFS->>Email: Send fulfillment email
+ Email->>Customer: License keys + download links
+```
+
+### 3.4 Fulfillment Service Implementation
+
+```python
+class FulfillmentService:
+ """Service for managing order fulfillment across channels."""
+
+ def __init__(
+ self,
+ db: Session,
+ digital_fulfillment: "DigitalFulfillmentService",
+ sync_queue: "SyncQueue",
+ ):
+ self.db = db
+ self.digital_fulfillment = digital_fulfillment
+ self.sync_queue = sync_queue
+
+ async def mark_shipped(
+ self,
+ order_id: int,
+ tracking_number: str | None = None,
+ carrier: str | None = None,
+ shipped_items: list[int] | None = None, # Partial shipment
+ ) -> Order:
+ """Mark order (or items) as shipped."""
+ order = self._get_order(order_id)
+
+ # Update order
+ order.status = OrderStatus.SHIPPED
+ order.shipped_at = datetime.utcnow()
+
+ # Add tracking info
+ if tracking_number:
+ self._add_status_history(
+ order,
+ OrderStatus.SHIPPED,
+ metadata={"tracking_number": tracking_number, "carrier": carrier}
+ )
+
+ # Queue sync to marketplace
+ if order.channel != OrderChannel.STOREFRONT:
+ self.sync_queue.enqueue(
+ SyncJob(
+ type="fulfillment",
+ order_id=order_id,
+ channel=order.channel,
+ data={"tracking_number": tracking_number, "carrier": carrier}
+ )
+ )
+
+ self.db.commit()
+ return order
+
+ async def fulfill_digital_items(self, order_id: int) -> Order:
+ """Fulfill digital items in order."""
+ order = self._get_order(order_id)
+
+ digital_items = [item for item in order.items if item.is_digital]
+
+ for item in digital_items:
+ await self.digital_fulfillment.fulfill_item(item)
+
+ # Check if fully fulfilled
+ if all(item.digital_fulfilled_at for item in digital_items):
+ if order.is_fully_digital:
+ order.status = OrderStatus.FULFILLED
+ order.fulfilled_at = datetime.utcnow()
+
+ self.db.commit()
+ return order
+
+
+class DigitalFulfillmentService:
+ """Service for fulfilling digital products."""
+
+ def __init__(
+ self,
+ db: Session,
+ codeswholesale_client: "CodesWholesaleClient",
+ email_service: "EmailService",
+ ):
+ self.db = db
+ self.codeswholesale = codeswholesale_client
+ self.email_service = email_service
+
+ async def fulfill_item(self, item: OrderItem) -> OrderItem:
+ """Fulfill a single digital order item."""
+ if item.digital_fulfilled_at:
+ return item # Already fulfilled
+
+ # Get license key based on supplier
+ if item.supplier == "codeswholesale":
+ key_data = await self._fulfill_from_codeswholesale(item)
+ else:
+ key_data = await self._fulfill_from_internal_pool(item)
+
+ # Update item
+ item.license_key = key_data.get("license_key")
+ item.download_url = key_data.get("download_url")
+ item.download_expiry = key_data.get("expiry")
+ item.digital_fulfilled_at = datetime.utcnow()
+ item.supplier_order_id = key_data.get("supplier_order_id")
+ item.supplier_cost = key_data.get("cost")
+
+ return item
+
+ async def _fulfill_from_codeswholesale(self, item: OrderItem) -> dict:
+ """Purchase key from CodesWholesale on-demand."""
+ # Get the marketplace product to find CodesWholesale product ID
+ product = item.product
+ mp = product.marketplace_product
+
+ if mp.marketplace != "codeswholesale":
+ raise ValueError(f"Product {product.id} is not from CodesWholesale")
+
+ # Purchase from CodesWholesale
+ result = await self.codeswholesale.purchase_code(
+ product_id=mp.marketplace_product_id,
+ quantity=item.quantity,
+ )
+
+ return {
+ "license_key": result["codes"][0]["code"], # First code for qty=1
+ "download_url": result.get("download_url"),
+ "supplier_order_id": result["order_id"],
+ "cost": result["total_price"],
+ }
+
+ async def _fulfill_from_internal_pool(self, item: OrderItem) -> dict:
+ """Get key from internal pre-loaded pool."""
+ # Implementation for stores who pre-load their own keys
+ pass
+```
+
+### 3.5 Letzshop Fulfillment Sync (GraphQL)
+
+```python
+LETZSHOP_UPDATE_FULFILLMENT = """
+mutation UpdateOrderFulfillment($orderId: ID!, $input: FulfillmentInput!) {
+ updateOrderFulfillment(orderId: $orderId, input: $input) {
+ order {
+ id
+ status
+ fulfillment {
+ status
+ trackingNumber
+ carrier
+ shippedAt
+ }
+ }
+ errors {
+ field
+ message
+ }
+ }
+}
+"""
+
+class LetzshopFulfillmentSync:
+ """Sync fulfillment status to Letzshop."""
+
+ async def sync_shipment(
+ self,
+ order: Order,
+ tracking_number: str | None,
+ carrier: str | None,
+ ) -> SyncResult:
+ """Update Letzshop with shipment info."""
+ variables = {
+ "orderId": order.channel_order_id,
+ "input": {
+ "status": "SHIPPED",
+ "trackingNumber": tracking_number,
+ "carrier": carrier,
+ "shippedAt": order.shipped_at.isoformat(),
+ }
+ }
+
+ result = await self._execute_mutation(
+ LETZSHOP_UPDATE_FULFILLMENT,
+ variables
+ )
+
+ if result.get("errors"):
+ return SyncResult(success=False, errors=result["errors"])
+
+ return SyncResult(success=True)
+```
+
+---
+
+## Part 4: Inventory Sync
+
+### 4.1 Inventory Sync Architecture
+
+```mermaid
+graph TB
+ subgraph "Inventory Changes"
+ OC[Order Created Reserve stock]
+ OF[Order Fulfilled Deduct stock]
+ OX[Order Cancelled Release stock]
+ MA[Manual Adjustment]
+ SI[Stock Import]
+ end
+
+ subgraph "Inventory Service"
+ IS[Inventory Service]
+ EQ[Event Queue]
+ end
+
+ subgraph "Sync Strategy"
+ RT[Real-time Sync For API marketplaces]
+ SC[Scheduled Batch For file-based]
+ end
+
+ subgraph "Outbound"
+ LS_S[Letzshop CSV/GraphQL]
+ AZ_S[Amazon API]
+ EB_S[eBay API]
+ end
+
+ OC --> IS
+ OF --> IS
+ OX --> IS
+ MA --> IS
+ SI --> IS
+
+ IS --> EQ
+ EQ --> RT
+ EQ --> SC
+
+ RT --> AZ_S
+ RT --> EB_S
+ SC --> LS_S
+```
+
+### 4.2 Sync Strategies
+
+| Strategy | Use Case | Trigger | Marketplaces |
+|----------|----------|---------|--------------|
+| **Real-time** | API-based marketplaces | Inventory change event | Amazon, eBay |
+| **Scheduled Batch** | File-based or rate-limited | Cron job (configurable) | Letzshop |
+| **On-demand** | Manual trigger | Store action | All |
+
+### 4.3 Inventory Data Model Extensions
+
+```python
+class InventorySyncConfig(Base, TimestampMixin):
+ """Per-store, per-marketplace sync configuration."""
+ __tablename__ = "inventory_sync_configs"
+
+ id = Column(Integer, primary_key=True)
+ store_id = Column(Integer, ForeignKey("stores.id"), nullable=False)
+ marketplace = Column(String, nullable=False) # 'letzshop', 'amazon', 'ebay'
+
+ # === SYNC SETTINGS ===
+ is_enabled = Column(Boolean, default=True)
+ sync_strategy = Column(String, default="scheduled") # 'realtime', 'scheduled', 'manual'
+ sync_interval_minutes = Column(Integer, default=15) # For scheduled
+
+ # === CREDENTIALS ===
+ api_credentials = Column(JSON) # Encrypted credentials
+
+ # === STOCK RULES ===
+ safety_stock = Column(Integer, default=0) # Reserve buffer
+ out_of_stock_threshold = Column(Integer, default=0)
+ sync_zero_stock = Column(Boolean, default=True) # Sync when stock=0
+
+ # === STATUS ===
+ last_sync_at = Column(DateTime)
+ last_sync_status = Column(String)
+ last_sync_error = Column(Text)
+ items_synced_count = Column(Integer, default=0)
+
+ __table_args__ = (
+ UniqueConstraint("store_id", "marketplace", name="uq_inventory_sync_store_marketplace"),
+ )
+
+
+class InventorySyncLog(Base, TimestampMixin):
+ """Log of inventory sync operations."""
+ __tablename__ = "inventory_sync_logs"
+
+ id = Column(Integer, primary_key=True)
+ store_id = Column(Integer, ForeignKey("stores.id"), nullable=False)
+ marketplace = Column(String, nullable=False)
+
+ sync_type = Column(String) # 'full', 'incremental', 'single_product'
+ started_at = Column(DateTime, nullable=False)
+ completed_at = Column(DateTime)
+
+ status = Column(String) # 'success', 'partial', 'failed'
+ items_processed = Column(Integer, default=0)
+ items_succeeded = Column(Integer, default=0)
+ items_failed = Column(Integer, default=0)
+
+ errors = Column(JSON) # List of errors
+```
+
+### 4.4 Inventory Sync Service
+
+```python
+class InventorySyncService:
+ """Service for syncing inventory to marketplaces."""
+
+ def __init__(
+ self,
+ db: Session,
+ sync_adapters: dict[str, "MarketplaceSyncAdapter"],
+ ):
+ self.db = db
+ self.adapters = sync_adapters
+
+ async def sync_inventory_change(
+ self,
+ product_id: int,
+ new_quantity: int,
+ change_reason: str,
+ ):
+ """Handle inventory change event - trigger real-time syncs."""
+ product = self.db.query(Product).get(product_id)
+ store_id = product.store_id
+
+ # Get enabled real-time sync configs
+ configs = self.db.query(InventorySyncConfig).filter(
+ InventorySyncConfig.store_id == store_id,
+ InventorySyncConfig.is_enabled == True,
+ InventorySyncConfig.sync_strategy == "realtime",
+ ).all()
+
+ for config in configs:
+ adapter = self.adapters.get(config.marketplace)
+ if adapter:
+ await self._sync_single_product(adapter, config, product, new_quantity)
+
+ async def run_scheduled_sync(self, store_id: int, marketplace: str):
+ """Run scheduled batch sync for a marketplace."""
+ config = self._get_sync_config(store_id, marketplace)
+ adapter = self.adapters.get(marketplace)
+
+ log = InventorySyncLog(
+ store_id=store_id,
+ marketplace=marketplace,
+ sync_type="full",
+ started_at=datetime.utcnow(),
+ status="running",
+ )
+ self.db.add(log)
+ self.db.commit()
+
+ try:
+ # Get all products for this store linked to this marketplace
+ products = self._get_products_for_sync(store_id, marketplace)
+
+ # Build inventory update payload
+ inventory_data = []
+ for product in products:
+ available_qty = self._calculate_available_quantity(product, config)
+ inventory_data.append({
+ "sku": product.store_sku or product.marketplace_product.marketplace_product_id,
+ "quantity": available_qty,
+ })
+
+ # Sync via adapter
+ result = await adapter.sync_inventory_batch(config, inventory_data)
+
+ log.completed_at = datetime.utcnow()
+ log.status = "success" if result.all_succeeded else "partial"
+ log.items_processed = len(inventory_data)
+ log.items_succeeded = result.succeeded_count
+ log.items_failed = result.failed_count
+ log.errors = result.errors
+
+ config.last_sync_at = datetime.utcnow()
+ config.last_sync_status = log.status
+ config.items_synced_count = log.items_succeeded
+
+ except Exception as e:
+ log.completed_at = datetime.utcnow()
+ log.status = "failed"
+ log.errors = [{"error": str(e)}]
+ config.last_sync_error = str(e)
+
+ self.db.commit()
+ return log
+
+ def _calculate_available_quantity(
+ self,
+ product: Product,
+ config: InventorySyncConfig,
+ ) -> int:
+ """Calculate quantity to report, applying safety stock."""
+ inventory = product.inventory_entries[0] if product.inventory_entries else None
+ if not inventory:
+ return 0
+
+ available = inventory.quantity - inventory.reserved_quantity
+ available -= config.safety_stock # Apply safety buffer
+
+ if available <= config.out_of_stock_threshold:
+ return 0 if config.sync_zero_stock else config.out_of_stock_threshold
+
+ return max(0, available)
+```
+
+### 4.5 Letzshop Inventory Sync
+
+```python
+class LetzshopInventorySyncAdapter:
+ """Sync inventory to Letzshop via CSV or GraphQL."""
+
+ async def sync_inventory_batch(
+ self,
+ config: InventorySyncConfig,
+ inventory_data: list[dict],
+ ) -> SyncResult:
+ """Sync inventory batch to Letzshop."""
+ # Option 1: CSV Export (upload to SFTP/web location)
+ if config.api_credentials.get("method") == "csv":
+ return await self._sync_via_csv(config, inventory_data)
+
+ # Option 2: GraphQL mutations
+ return await self._sync_via_graphql(config, inventory_data)
+
+ async def _sync_via_csv(
+ self,
+ config: InventorySyncConfig,
+ inventory_data: list[dict],
+ ) -> SyncResult:
+ """Generate and upload CSV inventory file."""
+ # Generate CSV
+ csv_content = self._generate_inventory_csv(inventory_data)
+
+ # Upload to configured location (SFTP, S3, etc.)
+ upload_location = config.api_credentials.get("upload_url")
+ # ... upload logic
+
+ return SyncResult(success=True, succeeded_count=len(inventory_data))
+
+ async def _sync_via_graphql(
+ self,
+ config: InventorySyncConfig,
+ inventory_data: list[dict],
+ ) -> SyncResult:
+ """Update inventory via GraphQL mutations."""
+ mutation = """
+ mutation UpdateInventory($input: InventoryUpdateInput!) {
+ updateInventory(input: $input) {
+ success
+ errors { sku, message }
+ }
+ }
+ """
+ # ... execute mutation
+```
+
+---
+
+## Part 5: Scheduler & Job Management
+
+### 5.1 Scheduled Jobs Overview
+
+| Job | Default Schedule | Configurable | Description |
+|-----|------------------|--------------|-------------|
+| `order_import_{marketplace}` | Every 5 min | Per store | Poll orders from marketplace |
+| `inventory_sync_{marketplace}` | Every 15 min | Per store | Sync inventory to marketplace |
+| `codeswholesale_catalog_sync` | Every 6 hours | Global | Update digital product catalog |
+| `product_price_sync` | Daily | Per store | Sync price changes to marketplace |
+| `sync_retry_failed` | Every 10 min | Global | Retry failed sync jobs |
+
+### 5.2 Job Configuration Model
+
+```python
+class ScheduledJob(Base, TimestampMixin):
+ """Configurable scheduled job."""
+ __tablename__ = "scheduled_jobs"
+
+ id = Column(Integer, primary_key=True)
+ store_id = Column(Integer, ForeignKey("stores.id"), nullable=True) # Null = global
+
+ job_type = Column(String, nullable=False) # 'order_import', 'inventory_sync', etc.
+ marketplace = Column(String) # Relevant marketplace if applicable
+
+ # === SCHEDULE ===
+ is_enabled = Column(Boolean, default=True)
+ cron_expression = Column(String) # Cron format
+ interval_minutes = Column(Integer) # Simple interval alternative
+
+ # === EXECUTION ===
+ last_run_at = Column(DateTime)
+ last_run_status = Column(String)
+ last_run_duration_ms = Column(Integer)
+ next_run_at = Column(DateTime)
+
+ # === RETRY CONFIG ===
+ max_retries = Column(Integer, default=3)
+ retry_delay_seconds = Column(Integer, default=60)
+
+ __table_args__ = (
+ UniqueConstraint("store_id", "job_type", "marketplace", name="uq_scheduled_job"),
+ )
+```
+
+---
+
+## Implementation Roadmap
+
+### Phase 1: Product Import (Weeks 1-2)
+
+**Goal:** Multi-marketplace product import with translations and digital product support.
+
+| Task | Priority | Status |
+|------|----------|--------|
+| Add product_type, is_digital fields to marketplace_products | High | [ ] |
+| Create marketplace_product_translations table | High | [ ] |
+| Create product_translations table | High | [ ] |
+| Implement BaseMarketplaceImporter pattern | High | [ ] |
+| Refactor LetzshopImporter from CSV processor | High | [ ] |
+| Add CodesWholesale catalog importer | High | [ ] |
+| Implement store override pattern on products | Medium | [ ] |
+| Add translation override support | Medium | [ ] |
+| Update API endpoints for translations | Medium | [ ] |
+
+**Detailed tasks:** See [Multi-Marketplace Product Architecture](../../development/migration/multi-marketplace-product-architecture.md)
+
+### Phase 2: Order Import (Weeks 3-4)
+
+**Goal:** Unified order management with multi-channel support.
+
+| Task | Priority | Status |
+|------|----------|--------|
+| Design unified orders schema | High | [ ] |
+| Create orders, order_items, order_status_history tables | High | [ ] |
+| Implement BaseOrderImporter pattern | High | [ ] |
+| Implement LetzshopOrderImporter (GraphQL) | High | [ ] |
+| Create order polling scheduler | High | [ ] |
+| Build order list/detail store UI | Medium | [ ] |
+| Add order notifications | Medium | [ ] |
+| Implement order search and filtering | Medium | [ ] |
+
+### Phase 3: Order Fulfillment Sync (Weeks 5-6)
+
+**Goal:** Sync fulfillment status back to marketplaces, handle digital delivery.
+
+| Task | Priority | Status |
+|------|----------|--------|
+| Implement FulfillmentService | High | [ ] |
+| Implement DigitalFulfillmentService | High | [ ] |
+| Integrate CodesWholesale key purchase API | High | [ ] |
+| Create fulfillment sync queue | High | [ ] |
+| Implement LetzshopFulfillmentSync | High | [ ] |
+| Build fulfillment UI (mark shipped, add tracking) | Medium | [ ] |
+| Digital fulfillment email templates | Medium | [ ] |
+| Fulfillment retry logic | Medium | [ ] |
+
+### Phase 4: Inventory Sync (Weeks 7-8)
+
+**Goal:** Real-time and scheduled inventory sync to marketplaces.
+
+| Task | Priority | Status |
+|------|----------|--------|
+| Create inventory_sync_configs table | High | [ ] |
+| Create inventory_sync_logs table | High | [ ] |
+| Implement InventorySyncService | High | [ ] |
+| Implement LetzshopInventorySyncAdapter | High | [ ] |
+| Create inventory change event system | High | [ ] |
+| Build sync configuration UI | Medium | [ ] |
+| Add sync status dashboard | Medium | [ ] |
+| Implement real-time sync for future marketplaces | Low | [ ] |
+
+---
+
+## Security Considerations
+
+### Credential Storage
+
+```python
+# All marketplace credentials should be encrypted at rest
+class MarketplaceCredential(Base, TimestampMixin):
+ """Encrypted marketplace credentials."""
+ __tablename__ = "marketplace_credentials"
+
+ id = Column(Integer, primary_key=True)
+ store_id = Column(Integer, ForeignKey("stores.id"), nullable=False)
+ marketplace = Column(String, nullable=False)
+
+ # Encrypted using application-level encryption
+ credentials_encrypted = Column(LargeBinary, nullable=False)
+
+ # Metadata (not sensitive)
+ credential_type = Column(String) # 'api_key', 'oauth', 'basic'
+ expires_at = Column(DateTime)
+ is_valid = Column(Boolean, default=True)
+ last_validated_at = Column(DateTime)
+```
+
+### API Rate Limiting
+
+- Respect marketplace API rate limits
+- Implement exponential backoff for failures
+- Queue operations to smooth out request bursts
+
+### Data Privacy
+
+- Customer data from marketplaces should follow GDPR guidelines
+- Implement data retention policies
+- Provide data export/deletion capabilities
+
+---
+
+## Monitoring & Observability
+
+### Key Metrics
+
+| Metric | Description | Alert Threshold |
+|--------|-------------|-----------------|
+| `order_import_lag_seconds` | Time since last successful order poll | > 15 min |
+| `inventory_sync_lag_seconds` | Time since last inventory sync | > 30 min |
+| `fulfillment_sync_queue_depth` | Pending fulfillment syncs | > 100 |
+| `sync_error_rate` | Failed syncs / total syncs | > 5% |
+| `digital_fulfillment_success_rate` | Successful key retrievals | < 95% |
+
+### Health Checks
+
+```python
+@router.get("/health/marketplace-integrations")
+async def check_marketplace_health(store_id: int):
+ """Check health of marketplace integrations."""
+ return {
+ "letzshop": {
+ "order_import": check_last_sync("letzshop", "order_import"),
+ "inventory_sync": check_last_sync("letzshop", "inventory_sync"),
+ },
+ "codeswholesale": {
+ "catalog_sync": check_last_sync("codeswholesale", "catalog"),
+ "api_status": await check_codeswholesale_api(),
+ }
+ }
+```
+
+---
+
+## Appendix A: CodesWholesale Integration Details
+
+### API Endpoints Used
+
+| Endpoint | Purpose | Frequency |
+|----------|---------|-----------|
+| `GET /products` | Fetch full catalog | Every 6 hours |
+| `GET /products/{id}` | Get single product details | On-demand |
+| `POST /orders` | Purchase license key | On order fulfillment |
+| `GET /orders/{id}` | Check order status | After purchase |
+| `GET /account/balance` | Check account balance | Periodically |
+
+### Product Catalog Mapping
+
+```python
+def map_codeswholesale_product(cw_product: dict) -> dict:
+ """Map CodesWholesale product to marketplace_product format."""
+ return {
+ "marketplace_product_id": f"cw_{cw_product['productId']}",
+ "marketplace": "codeswholesale",
+ "gtin": cw_product.get("ean"),
+ "product_type": "digital",
+ "is_digital": True,
+ "digital_delivery_method": "license_key",
+ "platform": cw_product.get("platform", "").lower(), # steam, origin, etc.
+ "region_restrictions": cw_product.get("regions"),
+ "price": cw_product["prices"][0]["value"], # Supplier cost
+ "currency": cw_product["prices"][0]["currency"],
+ "availability": "in_stock" if cw_product["quantity"] > 0 else "out_of_stock",
+ "attributes": {
+ "languages": cw_product.get("languages"),
+ "release_date": cw_product.get("releaseDate"),
+ }
+ }
+```
+
+---
+
+## Appendix B: Status Mapping Reference
+
+### Order Status Mapping
+
+| Orion Status | Letzshop | Amazon | eBay |
+|-----------------|----------|--------|------|
+| PENDING | PENDING | Pending | - |
+| CONFIRMED | PAID | Unshipped | Paid |
+| PROCESSING | PROCESSING | - | - |
+| SHIPPED | SHIPPED | Shipped | Shipped |
+| DELIVERED | DELIVERED | - | Delivered |
+| FULFILLED | - | - | - |
+| CANCELLED | CANCELLED | Cancelled | Cancelled |
+| REFUNDED | REFUNDED | Refunded | Refunded |
+
+---
+
+## Related Documents
+
+- [Multi-Marketplace Product Architecture](../../development/migration/multi-marketplace-product-architecture.md) - Detailed product data model
+- [Store Contact Inheritance](../../development/migration/store-contact-inheritance.md) - Override pattern reference
+- [Database Migrations](../../development/migration/database-migrations.md) - Migration guidelines
diff --git a/app/modules/marketplace/docs/data-model.md b/app/modules/marketplace/docs/data-model.md
new file mode 100644
index 00000000..1cb8cc4b
--- /dev/null
+++ b/app/modules/marketplace/docs/data-model.md
@@ -0,0 +1,297 @@
+# Marketplace Data Model
+
+Entity relationships and database schema for the marketplace module.
+
+## Entity Relationship Diagram
+
+```
+┌──────────────────────────┐
+│ Store │ (from tenancy module)
+└──────┬───────────────────┘
+ │ 1
+ │
+ ┌────┴──────────────────────────────────────────────┐
+ │ │ │ │
+ ▼ 1 ▼ * ▼ 0..1 ▼ *
+┌──────────────┐ ┌──────────────┐ ┌───────────────┐ ┌──────────────┐
+│ Marketplace │ │ Marketplace │ │ StoreLetzshop │ │ Letzshop │
+│ ImportJob │ │ Product │ │ Credentials │ │ Order │
+│ │ │ │ │ │ │ │
+│ source_url │ │ gtin, mpn │ │ api_key_enc │ │ letzshop_id │
+│ language │ │ sku, brand │ │ auto_sync │ │ sync_status │
+│ status │ │ price_cents │ │ last_sync_at │ │ customer │
+│ imported_cnt │ │ is_digital │ │ default_ │ │ total_amount │
+│ error_count │ │ marketplace │ │ carrier │ │ tracking │
+└──────┬───────┘ └──────┬───────┘ └───────────────┘ └──────┬───────┘
+ │ │ │
+ ▼ * ▼ * │
+┌──────────────┐ ┌──────────────────┐ │
+│ Marketplace │ │ Marketplace │ │
+│ ImportError │ │ Product │ │
+│ │ │ Translation │ │
+│ row_number │ │ │ │
+│ error_type │ │ language │ │
+│ error_msg │ │ title │ │
+│ row_data │ │ description │ │
+└──────────────┘ │ meta_title │ │
+ └──────────────────┘ │
+ │
+ ┌───────────────────────────────────────────────────────────┘
+ │
+ ▼ * ▼ * ▼ *
+┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
+│ Letzshop │ │ Letzshop │ │ Letzshop │
+│ FulfillmentQueue │ │ SyncLog │ │ Historical │
+│ │ │ │ │ ImportJob │
+│ operation │ │ operation_type │ │ │
+│ payload │ │ direction │ │ current_phase │
+│ status │ │ status │ │ current_page │
+│ attempts │ │ records_* │ │ orders_processed │
+└──────────────────┘ └──────────────────┘ └──────────────────┘
+
+┌──────────────────┐ ┌──────────────────┐
+│ Letzshop │ │ Store │
+│ StoreCache │ │ Onboarding │
+│ │ │ │
+│ letzshop_id │ │ status │
+│ name, slug │ │ current_step │
+│ claimed_by_ │ │ step_*_completed │
+│ store_id │ │ skipped_by_admin │
+└──────────────────┘ └──────────────────┘
+```
+
+## Models
+
+### Product Import Pipeline
+
+#### MarketplaceProduct
+
+Canonical product data imported from marketplace sources. Serves as a staging area — stores publish selected products to their own catalog.
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `marketplace_product_id` | String (unique) | Unique ID from marketplace |
+| `marketplace` | String | Source marketplace (e.g., "Letzshop") |
+| `gtin` | String | EAN/UPC barcode |
+| `mpn` | String | Manufacturer Part Number |
+| `sku` | String | Merchant's internal SKU |
+| `store_name` | String | Store name from marketplace |
+| `source_url` | String | CSV source URL |
+| `product_type_enum` | Enum | physical, digital, service, subscription |
+| `is_digital` | Boolean | Whether product is digital |
+| `digital_delivery_method` | Enum | download, email, in_app, streaming, license_key |
+| `brand` | String | Brand name |
+| `google_product_category` | String | Google Shopping category |
+| `condition` | String | new, used, refurbished |
+| `price_cents` | Integer | Price in cents |
+| `sale_price_cents` | Integer | Sale price in cents |
+| `currency` | String | Currency code (default EUR) |
+| `tax_rate_percent` | Decimal | VAT rate (default 17%) |
+| `image_link` | String | Main product image |
+| `additional_images` | JSON | Array of additional image URLs |
+| `attributes` | JSON | Flexible attributes |
+| `weight_grams` | Integer | Product weight |
+| `is_active` | Boolean | Whether product is active |
+
+**Relationships:** `translations` (MarketplaceProductTranslation), `store_products` (Product)
+
+#### MarketplaceProductTranslation
+
+Localized content for marketplace products.
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `marketplace_product_id` | FK | Parent product |
+| `language` | String | Language code (en, fr, de, lb) |
+| `title` | String | Product title |
+| `description` | Text | Full description |
+| `short_description` | Text | Short description |
+| `meta_title` | String | SEO title |
+| `meta_description` | String | SEO description |
+| `url_slug` | String | URL-friendly slug |
+| `source_import_id` | Integer | Import job that created this |
+| `source_file` | String | Source CSV file |
+
+**Constraint:** Unique (`marketplace_product_id`, `language`)
+
+#### MarketplaceImportJob
+
+Tracks CSV product import jobs with progress and metrics.
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `store_id` | FK | Target store |
+| `user_id` | FK | Who triggered the import |
+| `marketplace` | String | Source marketplace (default "Letzshop") |
+| `source_url` | String | CSV feed URL |
+| `language` | String | Import language for translations |
+| `status` | String | pending, processing, completed, failed, completed_with_errors |
+| `imported_count` | Integer | New products imported |
+| `updated_count` | Integer | Existing products updated |
+| `error_count` | Integer | Failed rows |
+| `total_processed` | Integer | Total rows processed |
+| `error_message` | Text | Error message if failed |
+| `celery_task_id` | String | Background task ID |
+| `started_at` | DateTime | Processing start time |
+| `completed_at` | DateTime | Processing end time |
+
+**Relationships:** `store`, `user`, `errors` (MarketplaceImportError, cascade delete)
+
+#### MarketplaceImportError
+
+Detailed error records for individual import failures.
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `import_job_id` | FK | Parent import job (cascade delete) |
+| `row_number` | Integer | Row in source CSV |
+| `identifier` | String | Product identifier (ID, GTIN, etc.) |
+| `error_type` | String | missing_title, missing_id, parse_error, validation_error |
+| `error_message` | String | Human-readable error |
+| `row_data` | JSON | Snapshot of key fields from failing row |
+
+### Letzshop Order Integration
+
+#### StoreLetzshopCredentials
+
+Encrypted API credentials and sync settings per store.
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `store_id` | FK (unique) | One credential set per store |
+| `api_key_encrypted` | String | Fernet-encrypted API key |
+| `api_endpoint` | String | GraphQL endpoint URL |
+| `auto_sync_enabled` | Boolean | Enable automatic order sync |
+| `sync_interval_minutes` | Integer | Auto-sync interval (5-1440) |
+| `test_mode_enabled` | Boolean | Test mode flag |
+| `default_carrier` | String | Default shipping carrier |
+| `carrier_*_label_url` | String | Per-carrier label URL prefixes |
+| `last_sync_at` | DateTime | Last sync timestamp |
+| `last_sync_status` | String | success, failed, partial |
+| `last_sync_error` | Text | Error message if failed |
+
+#### LetzshopOrder
+
+Tracks orders imported from Letzshop marketplace.
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `store_id` | FK | Store that owns this order |
+| `letzshop_order_id` | String | Letzshop order GID |
+| `letzshop_shipment_id` | String | Letzshop shipment GID |
+| `letzshop_order_number` | String | Human-readable order number |
+| `external_order_number` | String | Customer-facing reference |
+| `shipment_number` | String | Carrier shipment number |
+| `local_order_id` | FK | Linked local order (if any) |
+| `letzshop_state` | String | Current Letzshop state |
+| `customer_email` | String | Customer email |
+| `customer_name` | String | Customer name |
+| `customer_locale` | String | Customer language for invoicing |
+| `total_amount` | String | Order total |
+| `currency` | String | Currency code |
+| `raw_order_data` | JSON | Full order data from Letzshop |
+| `inventory_units` | JSON | List of inventory units |
+| `sync_status` | String | pending, confirmed, rejected, shipped |
+| `shipping_carrier` | String | Carrier code |
+| `tracking_number` | String | Tracking number |
+| `tracking_url` | String | Full tracking URL |
+| `shipping_country_iso` | String | Shipping country |
+| `billing_country_iso` | String | Billing country |
+| `order_date` | DateTime | Original order date from Letzshop |
+
+#### LetzshopFulfillmentQueue
+
+Outbound operation queue with retry logic for Letzshop fulfillment.
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `store_id` | FK | Store |
+| `order_id` | FK | Linked order |
+| `operation` | String | confirm_item, decline_item, set_tracking |
+| `payload` | JSON | Operation data |
+| `status` | String | pending, processing, completed, failed |
+| `attempts` | Integer | Retry count |
+| `max_attempts` | Integer | Max retries (default 3) |
+| `error_message` | Text | Last error |
+| `response_data` | JSON | Response from Letzshop |
+
+#### LetzshopSyncLog
+
+Audit trail for all Letzshop sync operations.
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `store_id` | FK | Store |
+| `operation_type` | String | order_import, confirm_inventory, set_tracking, etc. |
+| `direction` | String | inbound, outbound |
+| `status` | String | success, failed, partial |
+| `records_processed` | Integer | Total records |
+| `records_succeeded` | Integer | Successful records |
+| `records_failed` | Integer | Failed records |
+| `error_details` | JSON | Detailed error info |
+| `started_at` | DateTime | Operation start |
+| `completed_at` | DateTime | Operation end |
+| `duration_seconds` | Integer | Total duration |
+| `triggered_by` | String | user_id, scheduler, webhook |
+
+#### LetzshopHistoricalImportJob
+
+Tracks progress of historical order imports for real-time progress polling.
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `store_id` | FK | Store |
+| `user_id` | FK | Who triggered |
+| `status` | String | pending, fetching, processing, completed, failed |
+| `current_phase` | String | confirmed, declined |
+| `current_page` | Integer | Current pagination page |
+| `total_pages` | Integer | Total pages |
+| `shipments_fetched` | Integer | Shipments fetched so far |
+| `orders_processed` | Integer | Orders processed |
+| `orders_imported` | Integer | New orders imported |
+| `orders_updated` | Integer | Updated existing orders |
+| `orders_skipped` | Integer | Duplicate orders skipped |
+| `products_matched` | Integer | Products matched by EAN |
+| `products_not_found` | Integer | Products not found |
+| `confirmed_stats` | JSON | Stats for confirmed phase |
+| `declined_stats` | JSON | Stats for declined phase |
+| `celery_task_id` | String | Background task ID |
+
+### Supporting Models
+
+#### LetzshopStoreCache
+
+Cache of Letzshop marketplace store directory for browsing and claiming during signup.
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `letzshop_id` | String (unique) | Letzshop store identifier |
+| `slug` | String | URL slug |
+| `name` | String | Store name |
+| `merchant_name` | String | Merchant name |
+| `is_active` | Boolean | Active on Letzshop |
+| `description_en/fr/de` | Text | Localized descriptions |
+| `email`, `phone`, `website` | String | Contact info |
+| `street`, `zipcode`, `city`, `country_iso` | String | Address |
+| `latitude`, `longitude` | Float | Geo coordinates |
+| `categories` | JSON | Category array |
+| `social_media_links` | JSON | Social links |
+| `claimed_by_store_id` | FK | Store that claimed this listing |
+| `claimed_at` | DateTime | When claimed |
+| `last_synced_at` | DateTime | Last directory sync |
+
+#### StoreOnboarding
+
+Tracks completion of mandatory onboarding steps for new stores.
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `store_id` | FK (unique) | One record per store |
+| `status` | String | not_started, in_progress, completed, skipped |
+| `current_step` | Integer | Current onboarding step |
+| Step 1 | — | Merchant profile completion |
+| Step 2 | — | Letzshop API key setup + verification |
+| Step 3 | — | Product import (CSV URL set) |
+| Step 4 | — | Order sync (first sync job) |
+| `skipped_by_admin` | Boolean | Admin override |
+| `skipped_reason` | Text | Reason for skip |
diff --git a/app/modules/marketplace/docs/import-improvements.md b/app/modules/marketplace/docs/import-improvements.md
new file mode 100644
index 00000000..827303d8
--- /dev/null
+++ b/app/modules/marketplace/docs/import-improvements.md
@@ -0,0 +1,601 @@
+# Letzshop Order Import - Improvement Plan
+
+## Current Status (2025-12-17)
+
+### Schema Discovery Complete ✅
+
+After running GraphQL introspection queries, we have identified all available fields.
+
+### Available Fields Summary
+
+| Data | GraphQL Path | Notes |
+|------|-------------|-------|
+| **EAN/GTIN** | `variant.tradeId.number` | The product barcode |
+| **Trade ID Type** | `variant.tradeId.parser` | Format: gtin13, gtin14, gtin12, gtin8, isbn13, isbn10 |
+| **Brand Name** | `product._brand { ... on Brand { name } }` | Union type requires fragment |
+| **MPN** | `variant.mpn` | Manufacturer Part Number |
+| **SKU** | `variant.sku` | Merchant's internal SKU |
+| **Product Name** | `variant.product.name { en, fr, de }` | Translated names |
+| **Price** | `variant.price` | Unit price |
+| **Quantity** | Count of `inventoryUnits` | Each unit = 1 item |
+| **Customer Language** | `order.locale` | Language for invoice (en, fr, de) |
+| **Customer Country** | `order.shipAddress.country` | Country object |
+
+### Key Findings
+
+1. **EAN lives in `tradeId`** - Not a direct field on Variant, but nested in `tradeId.number`
+2. **TradeIdParser enum values**: `gtin14`, `gtin13` (EAN-13), `gtin12` (UPC), `gtin8`, `isbn13`, `isbn10`
+3. **Brand is a Union** - Must use `... on Brand { name }` fragment, also handles `BrandUnknown`
+4. **No quantity field** - Each InventoryUnit represents 1 item; count units to get quantity
+
+## Updated GraphQL Query
+
+```graphql
+query {
+ shipments(state: unconfirmed) {
+ nodes {
+ id
+ number
+ state
+ order {
+ id
+ number
+ email
+ total
+ completedAt
+ locale
+ shipAddress {
+ firstName
+ lastName
+ merchant
+ streetName
+ streetNumber
+ city
+ zipCode
+ phone
+ country {
+ name
+ iso
+ }
+ }
+ billAddress {
+ firstName
+ lastName
+ merchant
+ streetName
+ streetNumber
+ city
+ zipCode
+ phone
+ country {
+ name
+ iso
+ }
+ }
+ }
+ inventoryUnits {
+ id
+ state
+ variant {
+ id
+ sku
+ mpn
+ price
+ tradeId {
+ number
+ parser
+ }
+ product {
+ name { en fr de }
+ _brand {
+ ... on Brand { name }
+ }
+ }
+ }
+ }
+ tracking {
+ code
+ provider
+ }
+ }
+ }
+}
+```
+
+## Implementation Steps
+
+### Step 1: Update GraphQL Queries ✅ DONE
+Update in `app/services/letzshop/client_service.py`:
+- `QUERY_SHIPMENTS_UNCONFIRMED` ✅
+- `QUERY_SHIPMENTS_CONFIRMED` ✅
+- `QUERY_SHIPMENT_BY_ID` ✅
+- `QUERY_SHIPMENTS_PAGINATED_TEMPLATE` ✅ (new - for historical import)
+
+### Step 2: Update Order Service ✅ DONE
+Updated `create_order()` and `update_order_from_shipment()` in `app/services/letzshop/order_service.py`:
+- Extract `tradeId.number` as EAN ✅
+- Store MPN if available ✅
+- Store `locale` for invoice language ✅
+- Store shipping/billing country ISO codes ✅
+- Enrich inventory_units with EAN, MPN, SKU, product_name ✅
+
+**Database changes:**
+- Added `customer_locale` column to `LetzshopOrder`
+- Added `shipping_country_iso` column to `LetzshopOrder`
+- Added `billing_country_iso` column to `LetzshopOrder`
+- Migration: `a9a86cef6cca_add_letzshop_order_locale_and_country_.py`
+
+### Step 3: Match Products by EAN ✅ DONE (Basic)
+When importing orders:
+- Use `tradeId.number` (EAN) to find matching local product ✅
+- `_match_eans_to_products()` function added ✅
+- Returns match statistics (products_matched, products_not_found) ✅
+
+**TODO for later:**
+- ⬜ Decrease stock for matched product (needs careful implementation)
+- ⬜ Show match status in order detail view
+
+### Step 4: Update Frontend ✅ DONE (Historical Import)
+- Added "Import History" button to Orders tab ✅
+- Added historical import result display ✅
+- Added `importHistoricalOrders()` JavaScript function ✅
+
+**TODO for later:**
+- ⬜ Show product details in individual order view (EAN, MPN, SKU, match status)
+
+### Step 5: Historical Import Feature ✅ DONE
+Import all confirmed orders for:
+- Sales analytics (how many products sold)
+- Customer records
+- Historical data
+
+**Implementation:**
+- Pagination support with `get_all_shipments_paginated()` ✅
+- Deduplication by `letzshop_order_id` ✅
+- EAN matching during import ✅
+- Progress callback for large imports ✅
+
+**Endpoints Added:**
+- `POST /api/v1/admin/letzshop/stores/{id}/import-history` - Import historical orders
+- `GET /api/v1/admin/letzshop/stores/{id}/import-summary` - Get import statistics
+
+**Frontend:**
+- "Import History" button in Orders tab
+- Result display showing imported/updated/skipped counts
+
+**Tests:**
+- Unit tests in `tests/unit/services/test_letzshop_service.py` ✅
+- Manual test script `scripts/test_historical_import.py` ✅
+
+## Test Results (2025-12-17)
+
+### Query Test: PASSED ✅
+
+```
+Example shipment:
+ Shipment #: H43748338602
+ Order #: R702236251
+ Customer: miriana.leal@letzshop.lu
+ Locale: fr <<<< LANGUAGE
+ Total: 32.88 EUR
+
+ Ship to: Miriana Leal Ferreira
+ City: 1468 Luxembourg
+ Country: LU
+
+ Items (1):
+ - Pocket POP! Keychains: Marvel Avengers Infinity War - Iron Spider
+ SKU: 00889698273022
+ MPN: None
+ EAN: 00889698273022 (gtin14) <<<< BARCODE
+ Price: 5.88 EUR
+```
+
+### Known Issues / Letzshop API Bugs
+
+#### Bug 1: `_brand` field causes server error
+- **Error**: `NoMethodError: undefined method 'demodulize' for nil`
+- **Trigger**: Querying `_brand { ... on Brand { name } }` on some products
+- **Workaround**: Removed `_brand` from queries
+- **Status**: To report to Letzshop
+
+#### Bug 2: `tracking` field causes server error (ALL queries)
+- **Error**: `NoMethodError: undefined method 'demodulize' for nil`
+- **Trigger**: Including `tracking { code provider }` in ANY shipment query
+- **Tested and FAILS on**:
+ - Paginated queries: `shipments(state: confirmed, first: 10) { nodes { tracking { code provider } } }`
+ - Non-paginated queries: `shipments(state: confirmed) { nodes { tracking { code provider } } }`
+ - Single shipment queries: Also fails (Letzshop doesn't support `node(id:)` interface)
+- **Impact**: Cannot retrieve tracking numbers and carrier info at all
+- **Workaround**: None - tracking info is currently unavailable via API
+- **Status**: **CRITICAL - Must report to Letzshop**
+- **Date discovered**: 2025-12-17
+
+**Note**: Letzshop automatically creates tracking when orders are confirmed. The carrier picks up parcels. But we cannot retrieve this info due to the API bug.
+
+#### Bug 3: Product table missing `gtin` field ✅ FIXED
+- **Error**: `type object 'Product' has no attribute 'gtin'`
+- **Cause**: `gtin` field only existed on `MarketplaceProduct` (staging table), not on `Product` (operational table)
+- **Date discovered**: 2025-12-17
+- **Date fixed**: 2025-12-18
+- **Fix applied**:
+ 1. Migration `cb88bc9b5f86_add_gtin_columns_to_product_table.py` adds:
+ - `gtin` (String(50)) - the barcode number
+ - `gtin_type` (String(20)) - the format type (gtin13, gtin14, etc.)
+ - Indexes: `idx_product_gtin`, `idx_product_store_gtin`
+ 2. `models/database/product.py` updated with new columns
+ 3. `_match_eans_to_products()` now queries `Product.gtin`
+ 4. `get_products_by_eans()` now returns products by EAN lookup
+- **Status**: COMPLETE
+
+**GTIN Types Reference:**
+
+| Type | Digits | Common Name | Region/Use |
+|------|--------|-------------|------------|
+| gtin13 | 13 | EAN-13 | Europe (most common) |
+| gtin12 | 12 | UPC-A | North America |
+| gtin14 | 14 | ITF-14 | Logistics/cases |
+| gtin8 | 8 | EAN-8 | Small items |
+| isbn13 | 13 | ISBN-13 | Books |
+| isbn10 | 10 | ISBN-10 | Books (legacy) |
+
+Letzshop API returns:
+- `tradeId.number` → store in `gtin`
+- `tradeId.parser` → store in `gtin_type`
+
+**Letzshop Shipment States (from official docs):**
+
+| Letzshop State | Our sync_status | Description |
+|----------------|-----------------|-------------|
+| `unconfirmed` | `pending` | New order, needs store confirmation |
+| `confirmed` | `confirmed` | At least one product confirmed |
+| `declined` | `rejected` | All products rejected |
+
+Note: There is no "shipped" state in Letzshop. Shipping is tracked via the `tracking` field (code + provider), not as a state change.
+
+---
+
+## Historical Confirmed Orders Import
+
+### Purpose
+Import all historical confirmed orders from Letzshop to:
+1. **Sales Analytics** - Track total products sold, revenue by product/category
+2. **Customer Records** - Build customer database with order history
+3. **Inventory Reconciliation** - Understand what was sold to reconcile stock
+
+### Implementation Plan
+
+#### 1. Add "Import Historical Orders" Feature
+- New endpoint: `POST /api/v1/admin/letzshop/stores/{id}/import-history`
+- Parameters:
+ - `state`: confirmed/shipped/delivered (default: confirmed)
+ - `since`: Optional date filter (import orders after this date)
+ - `dry_run`: Preview without saving
+
+#### 2. Pagination Support
+Letzshop likely returns paginated results. Need to handle:
+```graphql
+query {
+ shipments(state: confirmed, first: 50, after: $cursor) {
+ pageInfo {
+ hasNextPage
+ endCursor
+ }
+ nodes { ... }
+ }
+}
+```
+
+#### 3. Deduplication
+- Check if order already exists by `letzshop_order_id` before inserting
+- Update existing orders if data changed
+
+#### 4. EAN Matching & Stock Adjustment
+When importing historical orders:
+- Match `tradeId.number` (EAN) to local products
+- Calculate total quantity sold per product
+- Option to adjust inventory based on historical sales
+
+#### 5. Customer Database
+Extract and store customer data:
+- Email (unique identifier)
+- Name (from shipping address)
+- Preferred language (from `order.locale`)
+- Order count, total spent
+
+#### 6. UI: Historical Import Page
+Admin interface to:
+- Trigger historical import
+- View import progress
+- See summary: X orders imported, Y customers added, Z products matched
+
+### Data Flow
+
+```
+Letzshop API (confirmed shipments)
+ │
+ ▼
+┌───────────────────────┐
+│ Import Service │
+│ - Fetch all pages │
+│ - Deduplicate │
+│ - Match EAN to SKU │
+└───────────────────────┘
+ │
+ ▼
+┌───────────────────────┐
+│ Database │
+│ - letzshop_orders │
+│ - customers │
+│ - inventory updates │
+└───────────────────────┘
+ │
+ ▼
+┌───────────────────────┐
+│ Analytics Dashboard │
+│ - Sales by product │
+│ - Revenue over time │
+│ - Customer insights │
+└───────────────────────┘
+```
+
+---
+
+## Schema Reference
+
+### Variant Fields
+```
+baseAmount: String
+baseAmountProduct: String
+baseUnit: String
+countOnHand: Int
+id: ID!
+images: [Image]!
+inPresale: Boolean!
+isMaster: Boolean!
+mpn: String
+price: Float!
+priceCrossed: Float
+pricePerUnit: Float
+product: Product!
+properties: [Property]!
+releaseAt: Iso8601Time
+sku: String
+tradeId: TradeId
+uniqueId: String
+url: String!
+```
+
+### TradeId Fields
+```
+isRestricted: Boolean
+number: String! # <-- THE EAN/GTIN
+parser: TradeIdParser! # <-- Format identifier
+```
+
+### TradeIdParser Enum
+```
+gtin14 - GTIN-14 (14 digits)
+gtin13 - GTIN-13 / EAN-13 (13 digits, most common in Europe)
+gtin12 - GTIN-12 / UPC-A (12 digits, common in North America)
+gtin8 - GTIN-8 / EAN-8 (8 digits)
+isbn13 - ISBN-13 (books)
+isbn10 - ISBN-10 (books)
+```
+
+### Brand (via BrandUnion)
+```
+BrandUnion = Brand | BrandUnknown
+
+Brand fields:
+ id: ID!
+ name: String!
+ identifier: String!
+ descriptor: String
+ logo: Attachment
+ url: String!
+```
+
+### InventoryUnit Fields
+```
+id: ID!
+price: Float!
+state: String!
+taxRate: Float!
+uniqueId: String
+variant: Variant
+```
+
+## Reference: Letzshop Frontend Shows
+
+From the Letzshop merchant interface:
+- Order number: R532332163
+- Shipment number: H74683403433
+- Product: "Pop! Rocks: DJ Khaled - DJ Khaled #237"
+- Brand: Funko
+- Internal merchant number: MH-FU-56757
+- Price: 16,95 €
+- Quantity: 1
+- Shipping: 2,99 €
+- Total: 19,94 €
+
+---
+
+## Completed (2025-12-18)
+
+### Order Stats Fix ✅
+- **Issue**: Order status cards (Pending, Confirmed, etc.) were showing incorrect counts
+- **Cause**: Stats were calculated client-side from only the visible page of orders
+- **Fix**:
+ 1. Added `get_order_stats()` method to `LetzshopOrderService`
+ 2. Added `LetzshopOrderStats` schema with pending/confirmed/rejected/shipped counts
+ 3. API now returns `stats` field with counts for ALL orders
+ 4. JavaScript uses server-side stats instead of client-side calculation
+- **Status**: COMPLETE
+
+### Tracking Investigation ✅
+- **Issue**: Letzshop API bug prevents querying tracking field
+- **Added**: `--tracking` option to `letzshop_introspect.py` to investigate workarounds
+- **Findings**: Bug is on Letzshop's side, no client-side workaround possible
+- **Recommendation**: Store tracking info locally after setting via API
+
+### Item-Level Confirmation ✅
+- **Issue**: Orders were being confirmed/declined at order level, but Letzshop requires item-level actions
+- **Letzshop Model**:
+ - Each `inventoryUnit` must be confirmed/declined individually via `confirmInventoryUnits` mutation
+ - `isAvailable: true` = confirmed, `isAvailable: false` = declined
+ - Inventory unit states: `unconfirmed` → `confirmed_available` / `confirmed_unavailable` / `returned`
+ - Shipment states derived from items: `unconfirmed` / `confirmed` / `declined`
+ - Partial confirmation allowed (some items confirmed, some declined)
+- **Fix**:
+ 1. Order detail modal now shows each item with product details (name, EAN, SKU, MPN, price)
+ 2. Per-item confirm/decline buttons for pending items
+ 3. Admin API endpoints for single-item and bulk operations:
+ - `POST /stores/{id}/orders/{id}/items/{id}/confirm`
+ - `POST /stores/{id}/orders/{id}/items/{id}/decline`
+ 4. Order status automatically updates based on item states:
+ - All items declined → order status = "declined"
+ - Any item confirmed → order status = "confirmed"
+
+### Terminology Update ✅
+- Changed "Rejected" to "Declined" throughout UI to match Letzshop terminology
+- Internal `sync_status` value remains "rejected" for backwards compatibility
+- Filter dropdown, status badges, and action buttons now use "Declined"
+- Added "Declined" stats card to orders dashboard
+
+### Historical Import: Multiple Phases ✅
+- Historical import now fetches both `confirmed` AND `unconfirmed` (pending) shipments
+- Note: "declined" is NOT a valid Letzshop shipment state - declined items are tracked at inventory unit level
+- Combined stats shown in import result
+
+---
+
+## Completed (2025-12-19)
+
+### Historical Import Progress Bar ✅
+Real-time progress feedback for historical import using background tasks with database polling.
+
+**Implementation:**
+- Background task (`app/tasks/letzshop_tasks.py`) runs historical import asynchronously
+- Progress stored in `LetzshopHistoricalImportJob` database model
+- Frontend polls status endpoint every 2 seconds
+- Two-phase import: confirmed orders first, then unconfirmed (pending) orders
+
+**Backend:**
+- `LetzshopHistoricalImportJob` model tracks: status, current_phase, current_page, shipments_fetched, orders_processed, confirmed_stats, declined_stats
+- `POST /stores/{id}/import-history` starts background job, returns job_id immediately
+- `GET /stores/{id}/import-history/{job_id}/status` returns current progress
+
+**Frontend:**
+- Progress panel shows: phase (confirmed/pending), page number, shipments fetched, orders processed
+- Disabled "Import History" button during import with spinner
+- Final result summary shows combined stats from both phases
+
+**Key Discovery:**
+- Letzshop API has NO "declined" shipment state
+- Valid states: `awaiting_order_completion`, `unconfirmed`, `completed`, `accepted`, `confirmed`
+- Declined items are tracked at inventory unit level with state `confirmed_unavailable`
+
+### Filter for Declined Items ✅
+Added ability to filter orders that have at least one declined/unavailable item.
+
+**Backend:**
+- `list_orders()` accepts `has_declined_items: bool` parameter
+- Uses JSON string contains check: `inventory_units.cast(String).contains("confirmed_unavailable")`
+- `get_order_stats()` returns `has_declined_items` count
+
+**Frontend:**
+- "Has Declined Items" toggle button in filters section
+- Shows count badge when there are orders with declined items
+- Toggles between all orders and filtered view
+
+**API:**
+- `GET /stores/{id}/orders?has_declined_items=true` - filter orders
+
+### Order Date Display ✅
+Orders now display the actual order date from Letzshop instead of the import date.
+
+**Database:**
+- Added `order_date` column to `LetzshopOrder` model
+- Migration: `2362c2723a93_add_order_date_to_letzshop_orders.py`
+
+**Backend:**
+- `create_order()` extracts `completedAt` from Letzshop order data and stores as `order_date`
+- `update_order_from_shipment()` populates `order_date` if not already set
+- Date parsing handles ISO format with timezone (including `Z` suffix)
+
+**Frontend:**
+- Order table displays `order_date` with fallback to `created_at` for legacy orders
+- Format: localized date/time string
+
+**Note:** Existing orders imported before this change will continue showing `created_at` until re-imported via historical import.
+
+### Search Filter ✅
+Added search functionality to find orders by order number, customer name, or email.
+
+**Backend:**
+- `list_orders()` accepts `search: str` parameter
+- Uses ILIKE for case-insensitive partial matching across:
+ - `letzshop_order_number`
+ - `customer_name`
+ - `customer_email`
+
+**Frontend:**
+- Search input field with magnifying glass icon
+- Debounced input (300ms) to avoid excessive API calls
+- Clear button to reset search
+- Resets to page 1 when search changes
+
+**API:**
+- `GET /stores/{id}/orders?search=query` - search orders
+
+---
+
+## Next Steps (TODO)
+
+### Priority 1: Stock Management
+When an order is confirmed/imported:
+1. Match EAN from order to local product catalog
+2. Decrease stock quantity for matched products
+3. Handle cases where product not found (alert/log)
+
+**Considerations:**
+- Should stock decrease happen on import or only on confirmation?
+- Need rollback mechanism if order is rejected
+- Handle partial matches (some items found, some not)
+
+### Priority 2: Invoice Generation
+Use `customer_locale` to generate invoices in customer's language:
+- Invoice template with multi-language support
+- PDF generation
+
+### Priority 3: Analytics Dashboard
+Build sales analytics based on imported orders:
+- Sales by product
+- Sales by time period
+- Customer statistics
+- Revenue breakdown
+
+---
+
+## Files Modified (2025-12-16 to 2025-12-19)
+
+| File | Changes |
+|------|---------|
+| `app/services/letzshop/client_service.py` | Added paginated query, updated all queries with EAN/locale/country |
+| `app/services/letzshop/order_service.py` | Historical import, EAN matching, order stats, has_declined_items filter, search filter, order_date extraction |
+| `models/database/letzshop.py` | Added locale/country/order_date columns, `LetzshopHistoricalImportJob` model |
+| `models/database/product.py` | Added `gtin` and `gtin_type` columns for EAN matching |
+| `models/schema/letzshop.py` | Added `LetzshopOrderStats`, `LetzshopHistoricalImportJobResponse`, `order_date` field |
+| `app/api/v1/admin/letzshop.py` | Import-history endpoints, has_declined_items filter, search filter, order_date in response |
+| `app/tasks/letzshop_tasks.py` | **NEW** - Background task for historical import with progress tracking |
+| `app/templates/admin/partials/letzshop-orders-tab.html` | Import History button, progress panel, declined items filter, search input, order_date display |
+| `static/admin/js/marketplace-letzshop.js` | Historical import polling, progress display, declined items filter, search functionality |
+| `tests/unit/services/test_letzshop_service.py` | Added tests for new functionality |
+| `scripts/test_historical_import.py` | Manual test script for historical import |
+| `scripts/debug_historical_import.py` | **NEW** - Debug script for shipment states and declined items |
+| `scripts/letzshop_introspect.py` | GraphQL schema introspection tool, tracking workaround tests |
+| `alembic/versions/a9a86cef6cca_*.py` | Migration for locale/country columns |
+| `alembic/versions/cb88bc9b5f86_*.py` | Migration for gtin columns on Product table |
+| `alembic/versions/*_add_historical_import_jobs.py` | **NEW** - Migration for LetzshopHistoricalImportJob table |
+| `alembic/versions/2362c2723a93_*.py` | **NEW** - Migration for order_date column |
diff --git a/app/modules/marketplace/docs/index.md b/app/modules/marketplace/docs/index.md
new file mode 100644
index 00000000..0e02475a
--- /dev/null
+++ b/app/modules/marketplace/docs/index.md
@@ -0,0 +1,73 @@
+# Marketplace (Letzshop)
+
+Letzshop marketplace integration for product sync, order import, and catalog synchronization.
+
+## Overview
+
+| Aspect | Detail |
+|--------|--------|
+| Code | `marketplace` |
+| Classification | Optional |
+| Dependencies | `inventory`, `catalog`, `orders` |
+| Status | Active |
+
+## Features
+
+- `letzshop_sync` — Letzshop API synchronization
+- `marketplace_import` — Product and order import
+- `product_sync` — Bidirectional product sync
+- `order_import` — Marketplace order import
+- `marketplace_analytics` — Marketplace performance metrics
+
+## Permissions
+
+| Permission | Description |
+|------------|-------------|
+| `marketplace.view_integration` | View marketplace integration |
+| `marketplace.manage_integration` | Manage marketplace settings |
+| `marketplace.sync_products` | Trigger product sync |
+
+## Data Model
+
+See [Data Model](data-model.md) for full entity relationships.
+
+- **MarketplaceProduct** — Canonical product data from marketplace sources
+- **MarketplaceProductTranslation** — Localized product content (title, description)
+- **MarketplaceImportJob** — CSV import job tracking with metrics
+- **MarketplaceImportError** — Detailed error records per import
+- **StoreLetzshopCredentials** — Encrypted API keys and sync settings
+- **LetzshopOrder** — Imported orders from Letzshop
+- **LetzshopFulfillmentQueue** — Outbound operation queue with retry logic
+- **LetzshopSyncLog** — Audit trail for sync operations
+- **LetzshopHistoricalImportJob** — Historical order import progress
+- **LetzshopStoreCache** — Marketplace store directory cache
+- **StoreOnboarding** — Store onboarding step tracking
+
+## API Endpoints
+
+| Method | Path | Description |
+|--------|------|-------------|
+| `*` | `/api/v1/admin/marketplace/*` | Admin marketplace management |
+| `*` | `/api/v1/admin/letzshop/*` | Letzshop-specific endpoints |
+| `*` | `/api/v1/admin/marketplace-products/*` | Product mapping management |
+
+## Scheduled Tasks
+
+| Task | Schedule | Description |
+|------|----------|-------------|
+| `marketplace.sync_store_directory` | Daily 02:00 | Sync store directory from Letzshop |
+
+## Configuration
+
+Letzshop API credentials are configured per-store via the admin UI.
+
+## Additional Documentation
+
+- [Data Model](data-model.md) — Entity relationships and database schema
+- [Architecture](architecture.md) — Multi-marketplace integration architecture
+- [Integration Guide](integration-guide.md) — CSV import guide with store/admin interfaces
+- [API Reference](api.md) — Letzshop GraphQL API reference
+- [Order Integration](order-integration.md) — Bidirectional order management with Letzshop
+- [Admin Guide](admin-guide.md) — Admin portal management guide
+- [Import Improvements](import-improvements.md) — GraphQL field mapping and EAN matching
+- [Job Queue](job-queue.md) — Job queue improvements and table harmonization
diff --git a/app/modules/marketplace/docs/integration-guide.md b/app/modules/marketplace/docs/integration-guide.md
new file mode 100644
index 00000000..7e784353
Binary files /dev/null and b/app/modules/marketplace/docs/integration-guide.md differ
diff --git a/app/modules/marketplace/docs/job-queue.md b/app/modules/marketplace/docs/job-queue.md
new file mode 100644
index 00000000..1f53eb0d
--- /dev/null
+++ b/app/modules/marketplace/docs/job-queue.md
@@ -0,0 +1,716 @@
+# Letzshop Jobs & Tables Improvements
+
+Implementation plan for improving the Letzshop management page jobs display and table harmonization.
+
+## Status: Completed
+
+### Completed
+- [x] Phase 1: Job Details Modal (commit cef80af)
+- [x] Phase 2: Add store column to jobs table
+- [x] Phase 3: Platform settings system (rows per page)
+- [x] Phase 4: Numbered pagination for jobs table
+- [x] Phase 5: Admin customer management page
+
+---
+
+## Overview
+
+This plan addresses 6 improvements:
+
+1. Job details modal with proper display
+2. Tab visibility fix when filters cleared
+3. Add store column to jobs table
+4. Harmonize all tables with table macro
+5. Platform-wide rows per page setting
+6. Build admin customer page
+
+---
+
+## 1. Job Details Modal
+
+### Current Issue
+- "View Details" shows a browser alert instead of a proper modal
+- No detailed breakdown of export results
+
+### Requirements
+- Create a proper modal for job details
+- For exports: show products exported per language file
+- Show store name/code
+- Show full timestamps and duration
+- Show error details if any
+
+### Implementation
+
+#### 1.1 Create Job Details Modal Template
+
+**File:** `app/templates/admin/partials/letzshop-jobs-table.html`
+
+Add modal after the table:
+
+```html
+
+
+
+
+
Job Details
+
+
+
+
+
+
+
Job ID: #
+
Type:
+
Status:
+
Store:
+
+
+
+
+
Started:
+
Completed:
+
Duration:
+
+
+
+
+
+
Export Details
+
Products exported:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Error
+
+
+
+
+
+
+```
+
+#### 1.2 Update JavaScript State
+
+**File:** `static/admin/js/marketplace-letzshop.js`
+
+Add state variables:
+```javascript
+showJobDetailsModal: false,
+selectedJobDetails: null,
+```
+
+Update `viewJobDetails` method:
+```javascript
+viewJobDetails(job) {
+ this.selectedJobDetails = job;
+ this.showJobDetailsModal = true;
+},
+```
+
+#### 1.3 Update API to Return Full Details
+
+**File:** `app/services/letzshop/order_service.py`
+
+Update `list_letzshop_jobs` to include `error_details` in the response for export jobs.
+
+---
+
+## 2. Tab Visibility Fix
+
+### Current Issue
+- When store filter is cleared, only 2 tabs appear (Orders, Exceptions)
+- Should show all tabs: Products, Orders, Exceptions, Jobs, Settings
+
+### Root Cause
+- Products, Jobs, and Settings tabs are wrapped in ``
+- This is intentional for store-specific features
+
+### Decision Required
+**Option A:** Keep current behavior (store-specific tabs hidden when no store)
+- Products, Jobs, Settings require a store context
+- Cross-store view only shows Orders and Exceptions
+
+**Option B:** Show all tabs but with "Select store" message
+- All tabs visible
+- Content shows prompt to select store
+
+### Recommended: Option A (Current Behavior)
+The current behavior is correct because:
+- Products tab shows store's Letzshop products (needs store)
+- Jobs tab shows store's jobs (needs store)
+- Settings tab configures store's Letzshop (needs store)
+- Orders and Exceptions can work cross-store
+
+**No change needed** - document this as intentional behavior.
+
+---
+
+## 3. Add Store Column to Jobs Table
+
+### Requirements
+- Add store name/code column to jobs table
+- Useful when viewing cross-store (future feature)
+- Prepare for reusable jobs component
+
+### Implementation
+
+#### 3.1 Update API Response
+
+**File:** `app/services/letzshop/order_service.py`
+
+Add store info to job dicts:
+```python
+# In list_letzshop_jobs, add to each job dict:
+"store_id": store_id,
+"store_name": store.name if store else None,
+"store_code": store.store_code if store else None,
+```
+
+Need to fetch store once at start of function.
+
+#### 3.2 Update Table Template
+
+**File:** `app/templates/admin/partials/letzshop-jobs-table.html`
+
+Add column header:
+```html
+
Store
+```
+
+Add column data:
+```html
+
+
+
+```
+
+#### 3.3 Update Schema
+
+**File:** `models/schema/letzshop.py`
+
+Update `LetzshopJobItem` to include store fields:
+```python
+store_id: int | None = None
+store_name: str | None = None
+store_code: str | None = None
+```
+
+---
+
+## 4. Harmonize Tables with Table Macro
+
+### Current State
+- Different tables use different pagination styles
+- Some use simple prev/next, others use numbered
+- Inconsistent styling
+
+### Requirements
+- All tables use `table` macro from `shared/macros/tables.html`
+- Numbered pagination with page numbers
+- Consistent column styling
+- Rows per page selector
+
+### Tables to Update
+
+| Table | File | Current Pagination |
+|-------|------|-------------------|
+| Jobs | letzshop-jobs-table.html | Simple prev/next |
+| Products | letzshop-products-tab.html | Simple prev/next |
+| Orders | letzshop-orders-tab.html | Simple prev/next |
+| Exceptions | letzshop-exceptions-tab.html | Simple prev/next |
+
+### Implementation
+
+#### 4.1 Create/Update Table Macro
+
+**File:** `app/templates/shared/macros/tables.html`
+
+Ensure numbered pagination macro exists:
+```html
+{% macro numbered_pagination(page_var, total_var, limit_var, on_change) %}
+
+
+ Showing
+ to
+ of
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endmacro %}
+```
+
+#### 4.2 Add Page Numbers Helper to JavaScript
+
+**File:** `static/shared/js/helpers.js` or inline
+
+```javascript
+function getPageNumbers(current, total, maxVisible = 5) {
+ if (total <= maxVisible) {
+ return Array.from({length: total}, (_, i) => i + 1);
+ }
+
+ const half = Math.floor(maxVisible / 2);
+ let start = Math.max(1, current - half);
+ let end = Math.min(total, start + maxVisible - 1);
+
+ if (end - start < maxVisible - 1) {
+ start = Math.max(1, end - maxVisible + 1);
+ }
+
+ return Array.from({length: end - start + 1}, (_, i) => start + i);
+}
+```
+
+#### 4.3 Update Each Table
+
+Update each table to use the macro and consistent styling.
+
+---
+
+## 5. Platform-Wide Rows Per Page Setting
+
+### Requirements
+- Global setting for default rows per page
+- Stored in platform settings (not per-user initially)
+- Used by all paginated tables
+- Options: 10, 20, 50, 100
+
+### Implementation
+
+#### 5.1 Add Platform Setting
+
+**File:** `models/database/platform_settings.py` (create if doesn't exist)
+
+```python
+class PlatformSettings(Base):
+ __tablename__ = "platform_settings"
+
+ id = Column(Integer, primary_key=True)
+ key = Column(String(100), unique=True, nullable=False)
+ value = Column(String(500), nullable=False)
+ updated_at = Column(DateTime, default=datetime.utcnow)
+```
+
+Or add to existing settings table if one exists.
+
+#### 5.2 Create Settings Service
+
+**File:** `app/services/platform_settings_service.py`
+
+```python
+class PlatformSettingsService:
+ def get_setting(self, db: Session, key: str, default: Any = None) -> Any:
+ setting = db.query(PlatformSettings).filter_by(key=key).first()
+ return setting.value if setting else default
+
+ def set_setting(self, db: Session, key: str, value: Any) -> None:
+ setting = db.query(PlatformSettings).filter_by(key=key).first()
+ if setting:
+ setting.value = str(value)
+ else:
+ setting = PlatformSettings(key=key, value=str(value))
+ db.add(setting)
+ db.flush()
+
+ def get_rows_per_page(self, db: Session) -> int:
+ return int(self.get_setting(db, "rows_per_page", "20"))
+```
+
+#### 5.3 Expose via API
+
+**File:** `app/api/v1/admin/settings.py`
+
+```python
+@router.get("/platform/rows-per-page")
+def get_rows_per_page(db: Session = Depends(get_db)):
+ return {"rows_per_page": platform_settings_service.get_rows_per_page(db)}
+
+@router.put("/platform/rows-per-page")
+def set_rows_per_page(
+ rows: int = Query(..., ge=10, le=100),
+ db: Session = Depends(get_db),
+ current_admin: User = Depends(get_current_admin_api),
+):
+ platform_settings_service.set_setting(db, "rows_per_page", rows)
+ db.commit()
+ return {"rows_per_page": rows}
+```
+
+#### 5.4 Load Setting in Frontend
+
+**File:** `static/shared/js/app.js` or similar
+
+```javascript
+// Load platform settings on app init
+async function loadPlatformSettings() {
+ try {
+ const response = await apiClient.get('/admin/settings/platform/rows-per-page');
+ window.platformSettings = {
+ rowsPerPage: response.rows_per_page || 20
+ };
+ } catch {
+ window.platformSettings = { rowsPerPage: 20 };
+ }
+}
+```
+
+#### 5.5 Use in Alpine Components
+
+```javascript
+// In each paginated component's init:
+this.limit = window.platformSettings?.rowsPerPage || 20;
+```
+
+---
+
+## Implementation Order
+
+1. **Phase 1: Job Details Modal** (Quick win)
+ - Add modal template
+ - Update JS state and methods
+ - Test with export jobs
+
+2. **Phase 2: Store Column** (Preparation)
+ - Update API response
+ - Update schema
+ - Add column to table
+
+3. **Phase 3: Platform Settings** (Foundation)
+ - Create settings model/migration
+ - Create service
+ - Create API endpoint
+ - Frontend integration
+
+4. **Phase 4: Table Harmonization** (Largest effort)
+ - Create/update table macros
+ - Add pagination helper function
+ - Update each table one by one
+ - Test thoroughly
+
+5. **Phase 5: Documentation**
+ - Update component documentation
+ - Add settings documentation
+
+---
+
+## Files to Create/Modify
+
+### New Files
+- `models/database/platform_settings.py` (if not exists)
+- `app/services/platform_settings_service.py`
+- `alembic/versions/xxx_add_platform_settings.py`
+
+### Modified Files
+- `app/templates/admin/partials/letzshop-jobs-table.html`
+- `app/templates/admin/partials/letzshop-products-tab.html`
+- `app/templates/admin/partials/letzshop-orders-tab.html`
+- `app/templates/admin/partials/letzshop-exceptions-tab.html`
+- `app/templates/shared/macros/tables.html`
+- `static/admin/js/marketplace-letzshop.js`
+- `static/shared/js/helpers.js` or `app.js`
+- `app/services/letzshop/order_service.py`
+- `models/schema/letzshop.py`
+- `app/api/v1/admin/settings.py` or new file
+
+---
+
+## 6. Admin Customer Page
+
+### Requirements
+- New page at `/admin/customers` to manage customers
+- List all customers across stores
+- Search and filter capabilities
+- View customer details and order history
+- Link to store context
+
+### Implementation
+
+#### 6.1 Database Model Check
+
+**File:** `models/database/customer.py`
+
+Verify Customer model exists with fields:
+- id, store_id
+- email, name, phone
+- shipping address fields
+- created_at, updated_at
+
+#### 6.2 Create Customer Service
+
+**File:** `app/services/customer_service.py`
+
+```python
+class CustomerService:
+ def get_customers(
+ self,
+ db: Session,
+ skip: int = 0,
+ limit: int = 20,
+ search: str | None = None,
+ store_id: int | None = None,
+ ) -> tuple[list[dict], int]:
+ """Get paginated customer list with optional filters."""
+ pass
+
+ def get_customer_detail(self, db: Session, customer_id: int) -> dict:
+ """Get customer with order history."""
+ pass
+
+ def get_customer_stats(self, db: Session, store_id: int | None = None) -> dict:
+ """Get customer statistics."""
+ pass
+```
+
+#### 6.3 Create API Endpoints
+
+**File:** `app/api/v1/admin/customers.py`
+
+```python
+router = APIRouter(prefix="/customers")
+
+@router.get("", response_model=CustomerListResponse)
+def get_customers(
+ skip: int = Query(0, ge=0),
+ limit: int = Query(20, ge=1, le=100),
+ search: str | None = Query(None),
+ store_id: int | None = Query(None),
+ db: Session = Depends(get_db),
+ current_admin: User = Depends(get_current_admin_api),
+):
+ """List all customers with filtering."""
+ pass
+
+@router.get("/stats", response_model=CustomerStatsResponse)
+def get_customer_stats(...):
+ """Get customer statistics."""
+ pass
+
+@router.get("/{customer_id}", response_model=CustomerDetailResponse)
+def get_customer_detail(...):
+ """Get customer with order history."""
+ pass
+```
+
+#### 6.4 Create Pydantic Schemas
+
+**File:** `models/schema/customer.py`
+
+```python
+class CustomerListItem(BaseModel):
+ id: int
+ email: str
+ name: str | None
+ phone: str | None
+ store_id: int
+ store_name: str | None
+ order_count: int
+ total_spent: float
+ created_at: datetime
+
+class CustomerListResponse(BaseModel):
+ customers: list[CustomerListItem]
+ total: int
+ skip: int
+ limit: int
+
+class CustomerDetailResponse(CustomerListItem):
+ shipping_address: str | None
+ orders: list[OrderSummary]
+
+class CustomerStatsResponse(BaseModel):
+ total: int
+ new_this_month: int
+ active: int # ordered in last 90 days
+ by_store: dict[str, int]
+```
+
+#### 6.5 Create Admin Page Route
+
+**File:** `app/routes/admin_pages.py`
+
+```python
+@router.get("/customers", response_class=HTMLResponse)
+async def admin_customers_page(request: Request, ...):
+ return templates.TemplateResponse(
+ "admin/customers.html",
+ {"request": request, "current_page": "customers"}
+ )
+```
+
+#### 6.6 Create Template
+
+**File:** `app/templates/admin/customers.html`
+
+Structure:
+- Page header with title and stats
+- Search bar and filters (store dropdown)
+- Customer table with pagination
+- Click row to view details modal
+
+#### 6.7 Create Alpine Component
+
+**File:** `static/admin/js/customers.js`
+
+```javascript
+function adminCustomers() {
+ return {
+ customers: [],
+ total: 0,
+ page: 1,
+ limit: 20,
+ search: '',
+ storeFilter: '',
+ loading: false,
+ stats: {},
+
+ async init() {
+ await Promise.all([
+ this.loadCustomers(),
+ this.loadStats()
+ ]);
+ },
+
+ async loadCustomers() { ... },
+ async loadStats() { ... },
+ async viewCustomer(id) { ... },
+ }
+}
+```
+
+#### 6.8 Add to Sidebar
+
+**File:** `app/templates/admin/partials/sidebar.html`
+
+Add menu item:
+```html
+{{ menu_item('customers', '/admin/customers', 'users', 'Customers') }}
+```
+
+### Customer Page Features
+
+| Feature | Description |
+|---------|-------------|
+| List View | Paginated table of all customers |
+| Search | Search by name, email, phone |
+| Store Filter | Filter by store |
+| Stats Cards | Total, new, active customers |
+| Detail Modal | Customer info + order history |
+| Quick Actions | View orders, send email |
+
+---
+
+## Implementation Order
+
+1. **Phase 1: Job Details Modal** (Quick win)
+ - Add modal template
+ - Update JS state and methods
+ - Test with export jobs
+
+2. **Phase 2: Store Column** (Preparation)
+ - Update API response
+ - Update schema
+ - Add column to table
+
+3. **Phase 3: Platform Settings** (Foundation)
+ - Create settings model/migration
+ - Create service
+ - Create API endpoint
+ - Frontend integration
+
+4. **Phase 4: Table Harmonization** (Largest effort)
+ - Create/update table macros
+ - Add pagination helper function
+ - Update each table one by one
+ - Test thoroughly
+
+5. **Phase 5: Admin Customer Page**
+ - Create service and API
+ - Create schemas
+ - Create template and JS
+ - Add to sidebar
+
+6. **Phase 6: Documentation**
+ - Update component documentation
+ - Add settings documentation
+ - Add customer page documentation
+
+---
+
+## Files to Create/Modify
+
+### New Files
+- `models/database/platform_settings.py` (if not exists)
+- `app/services/platform_settings_service.py`
+- `app/services/customer_service.py`
+- `app/api/v1/admin/customers.py`
+- `models/schema/customer.py`
+- `app/templates/admin/customers.html`
+- `static/admin/js/customers.js`
+- `alembic/versions/xxx_add_platform_settings.py`
+
+### Modified Files
+- `app/templates/admin/partials/letzshop-jobs-table.html`
+- `app/templates/admin/partials/letzshop-products-tab.html`
+- `app/templates/admin/partials/letzshop-orders-tab.html`
+- `app/templates/admin/partials/letzshop-exceptions-tab.html`
+- `app/templates/admin/partials/sidebar.html`
+- `app/templates/shared/macros/tables.html`
+- `static/admin/js/marketplace-letzshop.js`
+- `static/shared/js/helpers.js` or `app.js`
+- `app/services/letzshop/order_service.py`
+- `models/schema/letzshop.py`
+- `app/api/v1/admin/__init__.py`
+- `app/routes/admin_pages.py`
+
+---
+
+## Estimated Effort
+
+| Task | Effort |
+|------|--------|
+| Job Details Modal | Small |
+| Tab Visibility (no change) | None |
+| Store Column | Small |
+| Platform Settings | Medium |
+| Table Harmonization | Large |
+| Admin Customer Page | Medium |
+
+**Total:** Large effort
+
+---
+
+*Plan created: 2024-12-20*
diff --git a/app/modules/marketplace/docs/order-integration.md b/app/modules/marketplace/docs/order-integration.md
new file mode 100644
index 00000000..aaa1fe17
--- /dev/null
+++ b/app/modules/marketplace/docs/order-integration.md
@@ -0,0 +1,839 @@
+# Letzshop Order Integration Guide
+
+Complete guide for bidirectional order management with Letzshop marketplace via GraphQL API.
+
+## Table of Contents
+
+- [Overview](#overview)
+- [Architecture](#architecture)
+- [Setup and Configuration](#setup-and-configuration)
+- [Order Import](#order-import)
+- [Product Exceptions](#product-exceptions)
+- [Fulfillment Operations](#fulfillment-operations)
+- [Shipping and Tracking](#shipping-and-tracking)
+- [API Reference](#api-reference)
+- [Database Models](#database-models)
+- [Troubleshooting](#troubleshooting)
+
+---
+
+## Overview
+
+The Letzshop Order Integration provides bidirectional synchronization with Letzshop marketplace:
+
+- **Order Import**: Fetch unconfirmed orders from Letzshop via GraphQL
+- **Order Confirmation**: Confirm or reject inventory units
+- **Tracking Updates**: Set shipment tracking information
+- **Audit Trail**: Complete logging of all sync operations
+
+### Key Features
+
+- **Encrypted Credentials**: API keys stored with Fernet encryption
+- **Per-Store Configuration**: Each store manages their own Letzshop connection
+- **Admin Oversight**: Platform admins can manage any store's integration
+- **Queue-Based Fulfillment**: Retry logic for failed operations
+- **Multi-Channel Support**: Orders tracked with channel attribution
+
+---
+
+## Architecture
+
+### System Components
+
+```
+ ┌─────────────────────────────────────────┐
+ │ Frontend Interfaces │
+ ├─────────────────────────────────────────┤
+ │ Store Portal Admin Portal │
+ │ /store/letzshop /admin/letzshop │
+ └─────────────────────────────────────────┘
+ │
+ ┌─────────────────────────────────────────┐
+ │ API Layer │
+ ├─────────────────────────────────────────┤
+ │ /api/v1/store/letzshop/* │
+ │ /api/v1/admin/letzshop/* │
+ └─────────────────────────────────────────┘
+ │
+ ┌─────────────────────────────────────────┐
+ │ Service Layer │
+ ├─────────────────────────────────────────┤
+ │ LetzshopClient CredentialsService│
+ │ (GraphQL) (Encryption) │
+ └─────────────────────────────────────────┘
+ │
+ ┌─────────────────────────────────────────┐
+ │ Data Layer │
+ ├─────────────────────────────────────────┤
+ │ StoreLetzshopCredentials │
+ │ LetzshopOrder │
+ │ LetzshopFulfillmentQueue │
+ │ LetzshopSyncLog │
+ └─────────────────────────────────────────┘
+ │
+ ┌─────────────────────────────────────────┐
+ │ Letzshop GraphQL API │
+ │ https://letzshop.lu/graphql │
+ └─────────────────────────────────────────┘
+```
+
+### Data Flow
+
+1. **Credentials Setup**: Store/Admin stores encrypted API key
+2. **Order Import**: System fetches unconfirmed shipments from Letzshop
+3. **Order Processing**: Orders stored locally with Letzshop IDs
+4. **Fulfillment**: Store confirms/rejects orders, sets tracking
+5. **Sync Back**: Operations sent to Letzshop via GraphQL mutations
+
+---
+
+## Setup and Configuration
+
+### Prerequisites
+
+- Letzshop API key (obtained from Letzshop merchant portal)
+- Active store account on the platform
+
+### Step 1: Configure API Credentials
+
+#### Via Store Portal
+
+1. Navigate to **Settings > Letzshop Integration**
+2. Enter your Letzshop API key
+3. Click **Test Connection** to verify
+4. Enable **Auto-Sync** if desired (optional)
+5. Click **Save**
+
+#### Via Admin Portal
+
+1. Navigate to **Marketplace > Letzshop**
+2. Select the store from the list
+3. Click **Configure Credentials**
+4. Enter the API key
+5. Click **Save & Test**
+
+### Step 2: Test Connection
+
+```bash
+# Test connection via API
+curl -X POST /api/v1/store/letzshop/test \
+ -H "Authorization: Bearer $TOKEN"
+```
+
+Response:
+```json
+{
+ "success": true,
+ "message": "Connection successful",
+ "response_time_ms": 245.5
+}
+```
+
+### Configuration Options
+
+| Setting | Default | Description |
+|---------|---------|-------------|
+| `api_endpoint` | `https://letzshop.lu/graphql` | GraphQL endpoint URL |
+| `auto_sync_enabled` | `false` | Enable automatic order sync |
+| `sync_interval_minutes` | `15` | Auto-sync interval (5-1440 minutes) |
+
+---
+
+## Order Import
+
+### Manual Import
+
+Import orders on-demand via the store portal or API:
+
+```bash
+# Trigger order import
+curl -X POST /api/v1/store/letzshop/orders/import \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{"operation": "order_import"}'
+```
+
+Response:
+```json
+{
+ "success": true,
+ "message": "Import completed: 5 imported, 2 updated",
+ "orders_imported": 5,
+ "orders_updated": 2,
+ "errors": []
+}
+```
+
+### What Gets Imported
+
+The import fetches **unconfirmed shipments** from Letzshop containing:
+
+- Order ID and number
+- Customer email and name
+- Order total and currency
+- Inventory units (products to fulfill)
+- Shipping/billing addresses
+- Current order state
+
+### Order States
+
+| Letzshop State | Description |
+|----------------|-------------|
+| `unconfirmed` | Awaiting store confirmation |
+| `confirmed` | Store confirmed, ready to ship |
+| `shipped` | Tracking number set |
+| `delivered` | Delivery confirmed |
+| `returned` | Items returned |
+
+### Sync Status
+
+Local orders track their sync status:
+
+| Status | Description |
+|--------|-------------|
+| `pending` | Imported, awaiting action |
+| `confirmed` | Confirmed with Letzshop |
+| `rejected` | Rejected with Letzshop |
+| `shipped` | Tracking set with Letzshop |
+
+---
+
+## Product Exceptions
+
+When importing orders from Letzshop, products are matched by GTIN. If a product is not found in the store's catalog, the system **gracefully imports the order** with a placeholder product and creates an exception record for resolution.
+
+### Exception Workflow
+
+```
+Import Order → Product not found by GTIN
+ │
+ ▼
+ Create order with placeholder
+ + Flag item: needs_product_match=True
+ + Create OrderItemException record
+ │
+ ▼
+ Exception appears in QC dashboard
+ │
+ ┌───────────┴───────────┐
+ │ │
+ Resolve Ignore
+ (assign product) (with reason)
+ │ │
+ ▼ ▼
+ Order can be confirmed Still blocks confirmation
+```
+
+### Exception Types
+
+| Type | Description |
+|------|-------------|
+| `product_not_found` | GTIN not in store's product catalog |
+| `gtin_mismatch` | GTIN format issue |
+| `duplicate_gtin` | Multiple products with same GTIN |
+
+### Exception Statuses
+
+| Status | Description | Blocks Confirmation |
+|--------|-------------|---------------------|
+| `pending` | Awaiting resolution | **Yes** |
+| `resolved` | Product assigned | No |
+| `ignored` | Marked as ignored | **Yes** |
+
+**Important:** Both `pending` and `ignored` exceptions block order confirmation to Letzshop.
+
+### Viewing Exceptions
+
+Navigate to **Marketplace > Letzshop > Exceptions** tab to see all unmatched products.
+
+The dashboard shows:
+- **Pending**: Exceptions awaiting resolution
+- **Resolved**: Exceptions that have been matched
+- **Ignored**: Exceptions marked as ignored
+- **Orders Affected**: Orders with at least one exception
+
+### Resolving Exceptions
+
+#### Via Admin UI
+
+1. Navigate to **Marketplace > Letzshop > Exceptions**
+2. Click **Resolve** on the pending exception
+3. Search for the correct product by name, SKU, or GTIN
+4. Select the product and click **Confirm**
+5. Optionally check "Apply to all exceptions with this GTIN" for bulk resolution
+
+#### Via API
+
+```bash
+# Resolve a single exception
+curl -X POST /api/v1/admin/order-exceptions/{exception_id}/resolve \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "product_id": 123,
+ "notes": "Matched to correct product manually"
+ }'
+
+# Bulk resolve all exceptions with same GTIN
+curl -X POST /api/v1/admin/order-exceptions/bulk-resolve?store_id=1 \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "gtin": "4006381333931",
+ "product_id": 123,
+ "notes": "Product imported to catalog"
+ }'
+```
+
+### Auto-Matching
+
+When products are imported to the store catalog (via product sync or manual import), the system automatically:
+
+1. Collects GTINs of newly imported products
+2. Finds pending exceptions with matching GTINs
+3. Resolves them by assigning the new product
+
+This happens during:
+- Single product import (`copy_to_store_catalog`)
+- Bulk marketplace sync
+
+### Exception Statistics
+
+Get counts via API:
+
+```bash
+curl -X GET /api/v1/admin/order-exceptions/stats?store_id=1 \
+ -H "Authorization: Bearer $TOKEN"
+```
+
+Response:
+```json
+{
+ "pending": 15,
+ "resolved": 42,
+ "ignored": 3,
+ "total": 60,
+ "orders_with_exceptions": 8
+}
+```
+
+For more details, see [Order Item Exception System](../orders/exceptions.md).
+
+---
+
+## Fulfillment Operations
+
+### Confirm Order
+
+Confirm that you can fulfill the order:
+
+```bash
+# Confirm all inventory units in an order
+curl -X POST /api/v1/store/letzshop/orders/{order_id}/confirm \
+ -H "Authorization: Bearer $TOKEN"
+
+# Or confirm specific units
+curl -X POST /api/v1/store/letzshop/orders/{order_id}/confirm \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{"inventory_unit_ids": ["unit_abc123", "unit_def456"]}'
+```
+
+### Reject Order
+
+Reject order if you cannot fulfill:
+
+```bash
+curl -X POST /api/v1/store/letzshop/orders/{order_id}/reject \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{"reason": "Out of stock"}'
+```
+
+### Set Tracking
+
+Add tracking information for shipment:
+
+```bash
+curl -X POST /api/v1/store/letzshop/orders/{order_id}/tracking \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "tracking_number": "1Z999AA10123456784",
+ "tracking_carrier": "ups"
+ }'
+```
+
+Supported carriers: `dhl`, `ups`, `fedex`, `post_lu`, etc.
+
+---
+
+## Shipping and Tracking
+
+The system captures shipping information from Letzshop and provides local shipping management features.
+
+### Letzshop Nomenclature
+
+Letzshop uses specific terminology for order references:
+
+| Term | Example | Description |
+|------|---------|-------------|
+| **Order Number** | `R532332163` | Customer-facing order reference |
+| **Shipment Number** | `H74683403433` | Carrier shipment ID for tracking |
+| **Hash ID** | `nvDv5RQEmCwbjo` | Internal Letzshop reference |
+
+### Order Fields
+
+Orders imported from Letzshop include:
+
+| Field | Description |
+|-------|-------------|
+| `external_order_number` | Letzshop order number (e.g., R532332163) |
+| `shipment_number` | Carrier shipment number (e.g., H74683403433) |
+| `shipping_carrier` | Carrier code (greco, colissimo, xpresslogistics) |
+| `tracking_number` | Tracking number (if available) |
+| `tracking_url` | Full tracking URL |
+
+### Carrier Detection
+
+The system automatically detects the carrier from Letzshop shipment data:
+
+| Carrier | Code | Label URL Prefix |
+|---------|------|------------------|
+| Greco | `greco` | `https://dispatchweb.fr/Tracky/Home/` |
+| Colissimo | `colissimo` | Configurable in settings |
+| XpressLogistics | `xpresslogistics` | Configurable in settings |
+
+### Mark as Shipped
+
+Mark orders as shipped locally (does **not** sync to Letzshop):
+
+```bash
+curl -X POST /api/v1/admin/orders/{order_id}/ship \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "tracking_number": "1Z999AA10123456784",
+ "tracking_url": "https://tracking.example.com/1Z999AA10123456784",
+ "shipping_carrier": "ups"
+ }'
+```
+
+**Note:** This updates the local order status to `shipped` and sets the `shipped_at` timestamp. It does not send anything to Letzshop API.
+
+### Download Shipping Label
+
+Get the shipping label URL for an order:
+
+```bash
+curl -X GET /api/v1/admin/orders/{order_id}/shipping-label \
+ -H "Authorization: Bearer $TOKEN"
+```
+
+Response:
+```json
+{
+ "shipment_number": "H74683403433",
+ "shipping_carrier": "greco",
+ "label_url": "https://dispatchweb.fr/Tracky/Home/H74683403433",
+ "tracking_number": null,
+ "tracking_url": null
+}
+```
+
+The label URL is constructed from:
+- **Carrier label URL prefix** (configured in Admin Settings)
+- **Shipment number** from the order
+
+### Carrier Label Settings
+
+Configure carrier label URL prefixes in **Admin > Settings > Shipping**:
+
+| Setting | Default | Description |
+|---------|---------|-------------|
+| Greco Label URL | `https://dispatchweb.fr/Tracky/Home/` | Greco tracking/label prefix |
+| Colissimo Label URL | *(empty)* | Colissimo tracking prefix |
+| XpressLogistics Label URL | *(empty)* | XpressLogistics prefix |
+
+The full label URL is: `{prefix}{shipment_number}`
+
+### Tracking Information
+
+Letzshop does not expose Greco tracking information via API. The tracking URL visible in the Letzshop web UI is auto-generated by Letzshop using the dispatchweb.fr prefix.
+
+For orders using Greco carrier:
+1. The shipment number (e.g., `H74683403433`) is captured during import
+2. The tracking URL can be constructed: `https://dispatchweb.fr/Tracky/Home/{shipment_number}`
+3. Use the Download Label feature to get this URL
+
+---
+
+## API Reference
+
+### Store Endpoints
+
+Base path: `/api/v1/store/letzshop`
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| GET | `/status` | Get integration status |
+| GET | `/credentials` | Get credentials (API key masked) |
+| POST | `/credentials` | Create/update credentials |
+| PATCH | `/credentials` | Partial update credentials |
+| DELETE | `/credentials` | Remove credentials |
+| POST | `/test` | Test stored credentials |
+| POST | `/test-key` | Test API key without saving |
+| GET | `/orders` | List Letzshop orders |
+| GET | `/orders/{id}` | Get order details |
+| POST | `/orders/import` | Import orders from Letzshop |
+| POST | `/orders/{id}/confirm` | Confirm order |
+| POST | `/orders/{id}/reject` | Reject order |
+| POST | `/orders/{id}/tracking` | Set tracking info |
+| GET | `/logs` | List sync logs |
+| GET | `/queue` | List fulfillment queue |
+
+### Admin Endpoints
+
+Base path: `/api/v1/admin/letzshop`
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| GET | `/stores` | List stores with Letzshop status |
+| GET | `/stores/{id}/credentials` | Get store credentials |
+| POST | `/stores/{id}/credentials` | Set store credentials |
+| PATCH | `/stores/{id}/credentials` | Update store credentials |
+| DELETE | `/stores/{id}/credentials` | Delete store credentials |
+| POST | `/stores/{id}/test` | Test store connection |
+| POST | `/test` | Test any API key |
+| GET | `/stores/{id}/orders` | List store's Letzshop orders |
+| POST | `/stores/{id}/sync` | Trigger sync for store |
+
+### Order Endpoints
+
+Base path: `/api/v1/admin/orders`
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| GET | `` | List orders (cross-store) |
+| GET | `/stats` | Get order statistics |
+| GET | `/stores` | Get stores with orders |
+| GET | `/{id}` | Get order details |
+| PATCH | `/{id}/status` | Update order status |
+| POST | `/{id}/ship` | Mark as shipped |
+| GET | `/{id}/shipping-label` | Get shipping label URL |
+
+### Exception Endpoints
+
+Base path: `/api/v1/admin/order-exceptions`
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| GET | `` | List exceptions |
+| GET | `/stats` | Get exception statistics |
+| GET | `/{id}` | Get exception details |
+| POST | `/{id}/resolve` | Resolve with product |
+| POST | `/{id}/ignore` | Mark as ignored |
+| POST | `/bulk-resolve` | Bulk resolve by GTIN |
+
+### Response Schemas
+
+#### Credentials Response
+
+```json
+{
+ "id": 1,
+ "store_id": 5,
+ "api_key_masked": "letz****",
+ "api_endpoint": "https://letzshop.lu/graphql",
+ "auto_sync_enabled": false,
+ "sync_interval_minutes": 15,
+ "last_sync_at": "2025-01-15T10:30:00Z",
+ "last_sync_status": "success",
+ "last_sync_error": null,
+ "created_at": "2025-01-01T00:00:00Z",
+ "updated_at": "2025-01-15T10:30:00Z"
+}
+```
+
+#### Order Response
+
+```json
+{
+ "id": 123,
+ "store_id": 5,
+ "letzshop_order_id": "gid://letzshop/Order/12345",
+ "letzshop_shipment_id": "gid://letzshop/Shipment/67890",
+ "letzshop_order_number": "LS-2025-001234",
+ "letzshop_state": "unconfirmed",
+ "customer_email": "customer@example.com",
+ "customer_name": "John Doe",
+ "total_amount": "99.99",
+ "currency": "EUR",
+ "sync_status": "pending",
+ "inventory_units": [
+ {"id": "gid://letzshop/InventoryUnit/111", "state": "unconfirmed"}
+ ],
+ "created_at": "2025-01-15T10:00:00Z",
+ "updated_at": "2025-01-15T10:00:00Z"
+}
+```
+
+---
+
+## Database Models
+
+### StoreLetzshopCredentials
+
+Stores encrypted API credentials per store.
+
+```python
+class StoreLetzshopCredentials(Base):
+ __tablename__ = "store_letzshop_credentials"
+
+ id: int # Primary key
+ store_id: int # FK to stores (unique)
+ api_key_encrypted: str # Fernet encrypted API key
+ api_endpoint: str # GraphQL endpoint URL
+ auto_sync_enabled: bool # Enable auto-sync
+ sync_interval_minutes: int # Sync interval
+ last_sync_at: datetime # Last sync timestamp
+ last_sync_status: str # success, failed, partial
+ last_sync_error: str # Error message if failed
+```
+
+### LetzshopOrder
+
+Tracks imported orders from Letzshop.
+
+```python
+class LetzshopOrder(Base):
+ __tablename__ = "letzshop_orders"
+
+ id: int # Primary key
+ store_id: int # FK to stores
+ letzshop_order_id: str # Letzshop order GID
+ letzshop_shipment_id: str # Letzshop shipment GID
+ letzshop_order_number: str # Human-readable order number
+ local_order_id: int # FK to orders (if imported locally)
+ letzshop_state: str # Current Letzshop state
+ customer_email: str # Customer email
+ customer_name: str # Customer name
+ total_amount: str # Order total
+ currency: str # Currency code
+ raw_order_data: JSON # Full order data from Letzshop
+ inventory_units: JSON # List of inventory units
+ sync_status: str # pending, confirmed, rejected, shipped
+ tracking_number: str # Tracking number (if set)
+ tracking_carrier: str # Carrier code
+```
+
+### LetzshopFulfillmentQueue
+
+Queue for outbound operations with retry logic.
+
+```python
+class LetzshopFulfillmentQueue(Base):
+ __tablename__ = "letzshop_fulfillment_queue"
+
+ id: int # Primary key
+ store_id: int # FK to stores
+ letzshop_order_id: int # FK to letzshop_orders
+ operation: str # confirm, reject, set_tracking
+ payload: JSON # Operation data
+ status: str # pending, processing, completed, failed
+ attempts: int # Retry count
+ max_attempts: int # Max retries (default 3)
+ error_message: str # Last error if failed
+ response_data: JSON # Response from Letzshop
+```
+
+### LetzshopSyncLog
+
+Audit trail for all sync operations.
+
+```python
+class LetzshopSyncLog(Base):
+ __tablename__ = "letzshop_sync_logs"
+
+ id: int # Primary key
+ store_id: int # FK to stores
+ operation_type: str # order_import, confirm, etc.
+ direction: str # inbound, outbound
+ status: str # success, failed, partial
+ records_processed: int # Total records
+ records_succeeded: int # Successful records
+ records_failed: int # Failed records
+ error_details: JSON # Detailed error info
+ started_at: datetime # Operation start time
+ completed_at: datetime # Operation end time
+ duration_seconds: int # Total duration
+ triggered_by: str # user_id, scheduler, webhook
+```
+
+---
+
+## Security
+
+### API Key Encryption
+
+API keys are encrypted using Fernet symmetric encryption:
+
+```python
+from app.utils.encryption import encrypt_value, decrypt_value
+
+# Encrypt before storing
+encrypted_key = encrypt_value(api_key)
+
+# Decrypt when needed
+api_key = decrypt_value(encrypted_key)
+```
+
+The encryption key is derived from the application's `jwt_secret_key` using PBKDF2.
+
+### Access Control
+
+- **Stores**: Can only manage their own Letzshop integration
+- **Admins**: Can manage any store's integration
+- **API Keys**: Never returned in plain text (always masked)
+
+---
+
+## Troubleshooting
+
+### Connection Failed
+
+**Symptoms**: "Connection failed" error when testing
+
+**Possible Causes**:
+- Invalid API key
+- API key expired
+- Network issues
+- Letzshop service unavailable
+
+**Solutions**:
+1. Verify API key in Letzshop merchant portal
+2. Regenerate API key if expired
+3. Check network connectivity
+4. Check Letzshop status page
+
+### Orders Not Importing
+
+**Symptoms**: Import runs but no orders appear
+
+**Possible Causes**:
+- No unconfirmed orders in Letzshop
+- API key doesn't have required permissions
+- Orders already imported
+
+**Solutions**:
+1. Check Letzshop dashboard for unconfirmed orders
+2. Verify API key has order read permissions
+3. Check existing orders with `sync_status: pending`
+
+### Fulfillment Failed
+
+**Symptoms**: Confirm/reject/tracking operations fail
+
+**Possible Causes**:
+- Order already processed
+- Invalid inventory unit IDs
+- API permission issues
+
+**Solutions**:
+1. Check order state in Letzshop
+2. Verify inventory unit IDs are correct
+3. Check fulfillment queue for retry status
+4. Review error message in response
+
+### Sync Logs
+
+Check sync logs for detailed operation history:
+
+```bash
+curl -X GET /api/v1/store/letzshop/logs \
+ -H "Authorization: Bearer $TOKEN"
+```
+
+### Order Has Unresolved Exceptions
+
+**Symptoms**: "Order has X unresolved exception(s)" error when confirming
+
+**Cause**: Order contains items that couldn't be matched to products during import
+
+**Solutions**:
+1. Navigate to **Marketplace > Letzshop > Exceptions** tab
+2. Find the pending exceptions for this order
+3. Either:
+ - **Resolve**: Assign the correct product from your catalog
+ - **Ignore**: Mark as ignored if product will never be matched (still blocks confirmation)
+4. Retry the confirmation after resolving all exceptions
+
+### Cannot Find Shipping Label
+
+**Symptoms**: "Download Label" returns empty or no URL
+
+**Possible Causes**:
+- Shipment number not captured during import
+- Carrier label URL prefix not configured
+- Unknown carrier type
+
+**Solutions**:
+1. Re-sync the order to capture shipment data
+2. Check **Admin > Settings > Shipping** for carrier URL prefixes
+3. Verify the order has a valid `shipping_carrier` and `shipment_number`
+
+---
+
+## Best Practices
+
+### For Stores
+
+1. **Test connection** after setting up credentials
+2. **Import orders regularly** (or enable auto-sync)
+3. **Confirm orders promptly** to avoid delays
+4. **Set tracking** as soon as shipment is dispatched
+5. **Monitor sync logs** for any failures
+
+### For Admins
+
+1. **Review store status** regularly via admin dashboard
+2. **Assist stores** with connection issues
+3. **Monitor sync logs** for platform-wide issues
+4. **Set up alerts** for failed syncs (optional)
+
+---
+
+## Related Documentation
+
+- [Order Item Exception System](../orders/exceptions.md)
+- [Marketplace Integration (CSV Import)](integration-guide.md)
+- [Store RBAC](../tenancy/rbac.md)
+- [Admin Integration Guide](../../backend/admin-integration-guide.md)
+- [Exception Handling](../../development/exception-handling.md)
+
+---
+
+## Version History
+
+- **v1.2** (2025-12-20): Shipping & Tracking enhancements
+ - Added `shipment_number`, `shipping_carrier`, `tracking_url` fields to orders
+ - Carrier detection from Letzshop shipment data (Greco, Colissimo, XpressLogistics)
+ - Mark as Shipped feature (local only, does not sync to Letzshop)
+ - Shipping label URL generation using configurable carrier prefixes
+ - Admin settings for carrier label URL prefixes
+
+- **v1.1** (2025-12-20): Product Exception System
+ - Graceful order import when products not found by GTIN
+ - Placeholder product per store for unmatched items
+ - Exception tracking with pending/resolved/ignored statuses
+ - Confirmation blocking until exceptions resolved
+ - Auto-matching when products are imported
+ - Exceptions tab in admin Letzshop management page
+ - Bulk resolution by GTIN
+
+- **v1.0** (2025-12-13): Initial Letzshop order integration
+ - GraphQL client for order import
+ - Encrypted credential storage
+ - Fulfillment operations (confirm, reject, tracking)
+ - Admin and store API endpoints
+ - Sync logging and queue management
diff --git a/app/modules/messaging/docs/architecture.md b/app/modules/messaging/docs/architecture.md
new file mode 100644
index 00000000..545c0eb6
--- /dev/null
+++ b/app/modules/messaging/docs/architecture.md
@@ -0,0 +1,243 @@
+# Messaging System Implementation
+
+This document describes the messaging system that enables threaded conversations between different platform participants.
+
+## Overview
+
+The messaging system supports three communication channels:
+
+1. **Admin <-> Store**: Platform administrators communicate with store users
+2. **Store <-> Customer**: Stores communicate with their customers
+3. **Admin <-> Customer**: Platform administrators communicate with customers
+
+## Architecture
+
+### Database Models
+
+Located in `models/database/message.py`:
+
+| Model | Description |
+|-------|-------------|
+| `Conversation` | Threaded conversation container with subject, type, and status |
+| `ConversationParticipant` | Links participants to conversations with unread tracking |
+| `Message` | Individual messages within a conversation |
+| `MessageAttachment` | File attachments for messages |
+
+### Enums
+
+| Enum | Values | Description |
+|------|--------|-------------|
+| `ConversationType` | `admin_store`, `store_customer`, `admin_customer` | Defines conversation channel |
+| `ParticipantType` | `admin`, `store`, `customer` | Type of participant |
+
+### Polymorphic Participants
+
+The system uses polymorphic relationships via `participant_type` + `participant_id`:
+- `admin` and `store` types reference `users.id`
+- `customer` type references `customers.id`
+
+### Multi-Tenant Isolation
+
+Conversations involving customers include a `store_id` to ensure proper data isolation. Store users can only see conversations within their store context.
+
+## Services
+
+### MessagingService (`app/services/messaging_service.py`)
+
+Core business logic for conversations and messages:
+
+| Method | Description |
+|--------|-------------|
+| `create_conversation()` | Create a new conversation with participants |
+| `get_conversation()` | Get conversation with access validation |
+| `list_conversations()` | Paginated list with filters |
+| `send_message()` | Send message with automatic unread updates |
+| `mark_conversation_read()` | Mark all messages read for participant |
+| `get_unread_count()` | Get total unread count for header badge |
+| `close_conversation()` | Close a conversation thread |
+| `reopen_conversation()` | Reopen a closed conversation |
+
+### MessageAttachmentService (`app/services/message_attachment_service.py`)
+
+File upload handling:
+
+| Method | Description |
+|--------|-------------|
+| `validate_and_store()` | Validate file type/size and store to disk |
+| `get_max_file_size_bytes()` | Get limit from platform settings |
+| `delete_attachment()` | Remove files from storage |
+
+**Allowed file types:**
+- Images: JPEG, PNG, GIF, WebP
+- Documents: PDF, Office documents
+- Archives: ZIP
+- Text: Plain text, CSV
+
+**Storage path pattern:** `uploads/messages/YYYY/MM/conversation_id/uuid.ext`
+
+## API Endpoints
+
+### Admin API (`/api/v1/admin/messages`)
+
+| Endpoint | Method | Description |
+|----------|--------|-------------|
+| `/messages` | GET | List conversations |
+| `/messages` | POST | Create conversation |
+| `/messages/unread-count` | GET | Get unread badge count |
+| `/messages/recipients` | GET | Get available recipients |
+| `/messages/{id}` | GET | Get conversation detail |
+| `/messages/{id}/messages` | POST | Send message (with attachments) |
+| `/messages/{id}/close` | POST | Close conversation |
+| `/messages/{id}/reopen` | POST | Reopen conversation |
+| `/messages/{id}/read` | PUT | Mark as read |
+| `/messages/{id}/preferences` | PUT | Update notification preferences |
+
+### Store API (`/api/v1/store/messages`)
+
+Same structure as admin, but with store context filtering. Stores can only:
+- See their own store_customer and admin_store conversations
+- Create store_customer conversations with their customers
+- Not initiate admin_store conversations (admins initiate those)
+
+## Frontend
+
+### Admin Interface
+
+- **Template:** `app/templates/admin/messages.html`
+- **JavaScript:** `static/admin/js/messages.js`
+
+Features:
+- Split-panel conversation list + message thread
+- Filters by type (stores/customers) and status (open/closed)
+- Compose modal for new conversations
+- File attachment support
+- 30-second polling for new messages
+- Header badge with unread count
+
+### Store Interface
+
+- **Template:** `app/templates/store/messages.html`
+- **JavaScript:** `static/store/js/messages.js`
+
+Similar to admin but with store-specific:
+- Only store_customer and admin_store channels
+- Compose modal for customer conversations only
+
+## Pydantic Schemas
+
+Located in `models/schema/message.py`:
+
+- `ConversationCreate` - Create request
+- `ConversationSummary` - List item with unread count
+- `ConversationDetailResponse` - Full thread with messages
+- `ConversationListResponse` - Paginated list
+- `MessageResponse` - Single message with attachments
+- `AttachmentResponse` - File metadata with download URL
+- `UnreadCountResponse` - For header badge
+
+## Configuration
+
+### Platform Setting
+
+The attachment size limit is configurable via platform settings:
+
+- **Key:** `message_attachment_max_size_mb`
+- **Default:** 10
+- **Category:** messaging
+
+## Storefront (Customer) Interface
+
+### API Endpoints (`/api/v1/storefront/messages`)
+
+| Endpoint | Method | Description |
+|----------|--------|-------------|
+| `/messages` | GET | List customer's conversations |
+| `/messages/unread-count` | GET | Get unread badge count |
+| `/messages/{id}` | GET | Get conversation detail |
+| `/messages/{id}/messages` | POST | Send reply message |
+| `/messages/{id}/read` | PUT | Mark as read |
+| `/messages/{id}/attachments/{att_id}` | GET | Download attachment |
+
+### Frontend
+
+- **Template:** `app/templates/storefront/account/messages.html`
+- **Page Route:** `/storefront/account/messages` and `/storefront/account/messages/{conversation_id}`
+
+Features:
+- Conversation list with unread badges
+- Filter by status (open/closed)
+- Thread view with message history
+- Reply form with file attachments
+- 30-second polling for new messages
+- Link from account dashboard with unread count
+
+### Limitations
+
+Customers can only:
+- View their `store_customer` conversations
+- Reply to existing conversations (cannot initiate)
+- Cannot close conversations
+
+---
+
+## Future Enhancements
+
+### Email Notifications (Requires Email Infrastructure)
+
+The messaging system is designed to support email notifications, but requires email infrastructure to be implemented first:
+
+**Prerequisites:**
+- SMTP configuration in settings (host, port, username, password)
+- Email service (`app/services/email_service.py`)
+- Email templates (`app/templates/emails/`)
+- Background task queue for async sending
+
+**Planned Implementation:**
+1. **MessageNotificationService** (`app/services/message_notification_service.py`)
+ - `notify_new_message()` - Send email to participants on new message
+ - Respect per-conversation `email_notifications` preference
+ - Include message preview and reply link
+
+2. **Email Template** (`app/templates/emails/new_message.html`)
+ - Subject: "New message: {conversation_subject}"
+ - Body: Sender name, message preview, link to reply
+
+3. **Integration Points:**
+ - Call `notify_new_message()` from `messaging_service.send_message()`
+ - Skip notification for sender (only notify other participants)
+ - Rate limit to prevent spam on rapid message exchanges
+
+**Database Support:**
+The `email_notifications` field on `ConversationParticipant` is already in place to store per-conversation preferences.
+
+### WebSocket Support (Optional)
+
+Real-time message delivery instead of 30-second polling:
+- Would require WebSocket infrastructure (e.g., FastAPI WebSocket, Redis pub/sub)
+- Significant infrastructure changes
+
+## Migration
+
+The messaging tables are created by migration `e3f4a5b6c7d8_add_messaging_tables.py`:
+
+```bash
+# Apply migration
+alembic upgrade head
+
+# Rollback
+alembic downgrade -1
+```
+
+## Navigation
+
+### Admin Sidebar
+Messages is available under "Platform Administration" section.
+
+### Store Sidebar
+Messages is available under "Sales" section.
+
+### Storefront Account Dashboard
+Messages card is available on the customer account dashboard with unread count badge.
+
+### Header Badge
+Both admin and store headers show an unread message count badge next to the messages icon.
diff --git a/app/modules/messaging/docs/data-model.md b/app/modules/messaging/docs/data-model.md
new file mode 100644
index 00000000..60bada37
--- /dev/null
+++ b/app/modules/messaging/docs/data-model.md
@@ -0,0 +1,290 @@
+# Messaging Data Model
+
+Entity relationships and database schema for the messaging module.
+
+## Entity Relationship Overview
+
+```
+Store 1──1 StoreEmailSettings
+Store 1──* StoreEmailTemplate
+Store 1──* Conversation 1──* Message 1──* MessageAttachment
+ └──* ConversationParticipant
+
+EmailTemplate 1──* EmailLog
+```
+
+## Models
+
+### EmailTemplate
+
+Multi-language email templates stored in database with Jinja2 variable interpolation.
+
+| Field | Type | Constraints | Description |
+|-------|------|-------------|-------------|
+| `id` | Integer | PK | Primary key |
+| `code` | String(100) | not null, indexed | Template identifier (e.g., "signup_welcome") |
+| `language` | String(5) | not null, default "en" | Language code |
+| `name` | String(255) | not null | Human-readable name |
+| `description` | Text | nullable | Template purpose description |
+| `category` | String(50) | not null, default "system", indexed | auth, orders, billing, system, marketing |
+| `subject` | String(500) | not null | Subject line (supports variables) |
+| `body_html` | Text | not null | HTML body content |
+| `body_text` | Text | nullable | Plain text fallback |
+| `variables` | Text | nullable | JSON list of expected variables |
+| `required_variables` | Text | nullable | JSON list of mandatory variables |
+| `is_active` | Boolean | not null, default True | Activation status |
+| `is_platform_only` | Boolean | not null, default False | If True, stores cannot override |
+| `created_at` | DateTime | tz-aware | Record creation time |
+| `updated_at` | DateTime | tz-aware | Record update time |
+
+**Unique Index**: `(code, language)`
+
+### StoreEmailTemplate
+
+Store-specific email template overrides. Stores can customize platform templates without modifying defaults.
+
+| Field | Type | Constraints | Description |
+|-------|------|-------------|-------------|
+| `id` | Integer | PK | Primary key |
+| `store_id` | Integer | FK, not null, indexed | Store owning the override |
+| `template_code` | String(100) | not null, indexed | References EmailTemplate.code |
+| `language` | String(5) | not null, default "en" | Language code |
+| `name` | String(255) | nullable | Custom name (null = use platform) |
+| `subject` | String(500) | not null | Custom subject line |
+| `body_html` | Text | not null | Custom HTML body |
+| `body_text` | Text | nullable | Custom plain text body |
+| `is_active` | Boolean | not null, default True | Activation status |
+| `created_at` | DateTime | tz-aware | Record creation time |
+| `updated_at` | DateTime | tz-aware | Record update time |
+
+**Unique Constraint**: `(store_id, template_code, language)`
+
+### EmailLog
+
+Email sending history and tracking for debugging, analytics, and compliance.
+
+| Field | Type | Constraints | Description |
+|-------|------|-------------|-------------|
+| `id` | Integer | PK | Primary key |
+| `template_code` | String(100) | nullable, indexed | Reference to template code |
+| `template_id` | Integer | FK, nullable | Reference to template |
+| `recipient_email` | String(255) | not null, indexed | Recipient address |
+| `recipient_name` | String(255) | nullable | Recipient name |
+| `subject` | String(500) | not null | Email subject line |
+| `body_html` | Text | nullable | HTML body snapshot |
+| `body_text` | Text | nullable | Plain text body snapshot |
+| `from_email` | String(255) | not null | Sender email address |
+| `from_name` | String(255) | nullable | Sender name |
+| `reply_to` | String(255) | nullable | Reply-to address |
+| `status` | String(20) | not null, default "pending", indexed | pending, sent, failed, bounced, delivered, opened, clicked |
+| `sent_at` | DateTime | nullable | When sent |
+| `delivered_at` | DateTime | nullable | When delivered |
+| `opened_at` | DateTime | nullable | When opened |
+| `clicked_at` | DateTime | nullable | When clicked |
+| `error_message` | Text | nullable | Error details if failed |
+| `retry_count` | Integer | not null, default 0 | Retry attempts |
+| `provider` | String(50) | nullable | smtp, sendgrid, mailgun, ses |
+| `provider_message_id` | String(255) | nullable, indexed | Provider's message ID |
+| `store_id` | Integer | FK, nullable, indexed | Associated store |
+| `user_id` | Integer | FK, nullable, indexed | Associated user |
+| `related_type` | String(50) | nullable | Related entity type |
+| `related_id` | Integer | nullable | Related entity ID |
+| `extra_data` | Text | nullable | JSON additional context |
+| `created_at` | DateTime | tz-aware | Record creation time |
+| `updated_at` | DateTime | tz-aware | Record update time |
+
+### StoreEmailSettings
+
+Per-store email sending configuration. One-to-one with Store.
+
+| Field | Type | Constraints | Description |
+|-------|------|-------------|-------------|
+| `id` | Integer | PK | Primary key |
+| `store_id` | Integer | FK, unique, not null | One-to-one with store |
+| `from_email` | String(255) | not null | Sender email address |
+| `from_name` | String(100) | not null | Sender display name |
+| `reply_to_email` | String(255) | nullable | Reply-to address |
+| `signature_text` | Text | nullable | Plain text signature |
+| `signature_html` | Text | nullable | HTML signature/footer |
+| `provider` | String(20) | not null, default "smtp" | smtp, sendgrid, mailgun, ses |
+| `smtp_host` | String(255) | nullable | SMTP server hostname |
+| `smtp_port` | Integer | nullable, default 587 | SMTP port |
+| `smtp_username` | String(255) | nullable | SMTP username |
+| `smtp_password` | String(500) | nullable | SMTP password (encrypted) |
+| `smtp_use_tls` | Boolean | not null, default True | Use TLS |
+| `smtp_use_ssl` | Boolean | not null, default False | Use SSL (port 465) |
+| `sendgrid_api_key` | String(500) | nullable | SendGrid API key (encrypted) |
+| `mailgun_api_key` | String(500) | nullable | Mailgun API key (encrypted) |
+| `mailgun_domain` | String(255) | nullable | Mailgun domain |
+| `ses_access_key_id` | String(100) | nullable | SES access key ID |
+| `ses_secret_access_key` | String(500) | nullable | SES secret key (encrypted) |
+| `ses_region` | String(50) | nullable, default "eu-west-1" | AWS region |
+| `is_configured` | Boolean | not null, default False | Has complete config |
+| `is_verified` | Boolean | not null, default False | Test email succeeded |
+| `last_verified_at` | DateTime | nullable, tz-aware | Last verification |
+| `verification_error` | Text | nullable | Last error message |
+| `created_at` | DateTime | tz-aware | Record creation time |
+| `updated_at` | DateTime | tz-aware | Record update time |
+
+**Index**: `(store_id, is_configured)`
+
+### AdminNotification
+
+Admin-specific notifications for system alerts and warnings.
+
+| Field | Type | Constraints | Description |
+|-------|------|-------------|-------------|
+| `id` | Integer | PK | Primary key |
+| `type` | String(50) | not null, indexed | system_alert, store_issue, import_failure |
+| `priority` | String(20) | not null, default "normal", indexed | low, normal, high, critical |
+| `title` | String(200) | not null | Notification title |
+| `message` | Text | not null | Notification message |
+| `is_read` | Boolean | not null, default False, indexed | Read status |
+| `read_at` | DateTime | nullable | When read |
+| `read_by_user_id` | Integer | FK, nullable | User who read it |
+| `action_required` | Boolean | not null, default False, indexed | Action needed |
+| `action_url` | String(500) | nullable | Link to relevant admin page |
+| `notification_metadata` | JSON | nullable | Additional context |
+| `created_at` | DateTime | tz-aware | Record creation time |
+| `updated_at` | DateTime | tz-aware | Record update time |
+
+### Conversation
+
+Threaded conversation between participants with multi-tenant isolation.
+
+| Field | Type | Constraints | Description |
+|-------|------|-------------|-------------|
+| `id` | Integer | PK | Primary key |
+| `conversation_type` | Enum | not null, indexed | admin_store, store_customer, admin_customer |
+| `subject` | String(500) | not null | Thread subject line |
+| `store_id` | Integer | FK, nullable, indexed | Multi-tenant isolation |
+| `is_closed` | Boolean | not null, default False | Closed status |
+| `closed_at` | DateTime | nullable | When closed |
+| `closed_by_type` | Enum | nullable | Type of closer |
+| `closed_by_id` | Integer | nullable | ID of closer |
+| `last_message_at` | DateTime | nullable, indexed | Last activity |
+| `message_count` | Integer | not null, default 0 | Total messages |
+| `created_at` | DateTime | tz-aware | Record creation time |
+| `updated_at` | DateTime | tz-aware | Record update time |
+
+**Composite Index**: `(conversation_type, store_id)`
+
+### ConversationParticipant
+
+Links participants (users or customers) to conversations with polymorphic relationships.
+
+| Field | Type | Constraints | Description |
+|-------|------|-------------|-------------|
+| `id` | Integer | PK | Primary key |
+| `conversation_id` | Integer | FK, not null, indexed | Parent conversation |
+| `participant_type` | Enum | not null | admin, store, customer |
+| `participant_id` | Integer | not null, indexed | Polymorphic participant ID |
+| `store_id` | Integer | FK, nullable | Store context |
+| `unread_count` | Integer | not null, default 0 | Unread messages |
+| `last_read_at` | DateTime | nullable | Last read time |
+| `email_notifications` | Boolean | not null, default True | Email notification pref |
+| `muted` | Boolean | not null, default False | Muted status |
+| `created_at` | DateTime | tz-aware | Record creation time |
+| `updated_at` | DateTime | tz-aware | Record update time |
+
+**Unique Constraint**: `(conversation_id, participant_type, participant_id)`
+**Composite Index**: `(participant_type, participant_id)`
+
+### Message
+
+Individual message within a conversation thread.
+
+| Field | Type | Constraints | Description |
+|-------|------|-------------|-------------|
+| `id` | Integer | PK | Primary key |
+| `conversation_id` | Integer | FK, not null, indexed | Parent conversation |
+| `sender_type` | Enum | not null | admin, store, customer |
+| `sender_id` | Integer | not null, indexed | Polymorphic sender ID |
+| `content` | Text | not null | Message body |
+| `is_system_message` | Boolean | not null, default False | System-generated flag |
+| `is_deleted` | Boolean | not null, default False | Soft delete flag |
+| `deleted_at` | DateTime | nullable | When deleted |
+| `deleted_by_type` | Enum | nullable | Type of deleter |
+| `deleted_by_id` | Integer | nullable | ID of deleter |
+| `created_at` | DateTime | tz-aware | Record creation time |
+| `updated_at` | DateTime | tz-aware | Record update time |
+
+**Composite Index**: `(conversation_id, created_at)`
+
+### MessageAttachment
+
+File attachments for messages.
+
+| Field | Type | Constraints | Description |
+|-------|------|-------------|-------------|
+| `id` | Integer | PK | Primary key |
+| `message_id` | Integer | FK, not null, indexed | Parent message |
+| `filename` | String(255) | not null | System filename |
+| `original_filename` | String(255) | not null | Original upload name |
+| `file_path` | String(1000) | not null | Storage path |
+| `file_size` | Integer | not null | File size in bytes |
+| `mime_type` | String(100) | not null | MIME type |
+| `is_image` | Boolean | not null, default False | Image flag |
+| `image_width` | Integer | nullable | Width in pixels |
+| `image_height` | Integer | nullable | Height in pixels |
+| `thumbnail_path` | String(1000) | nullable | Thumbnail path |
+| `created_at` | DateTime | tz-aware | Record creation time |
+| `updated_at` | DateTime | tz-aware | Record update time |
+
+## Enums
+
+### EmailCategory
+
+| Value | Description |
+|-------|-------------|
+| `auth` | Signup, password reset, verification |
+| `orders` | Order confirmations, shipping |
+| `billing` | Invoices, payment failures |
+| `system` | Team invites, notifications |
+| `marketing` | Newsletters, promotions |
+
+### EmailStatus
+
+| Value | Description |
+|-------|-------------|
+| `pending` | Queued for sending |
+| `sent` | Sent to provider |
+| `failed` | Send failed |
+| `bounced` | Bounced back |
+| `delivered` | Confirmed delivered |
+| `opened` | Recipient opened |
+| `clicked` | Link clicked |
+
+### EmailProvider
+
+| Value | Description |
+|-------|-------------|
+| `smtp` | Standard SMTP (all tiers) |
+| `sendgrid` | SendGrid API (Business+ tier) |
+| `mailgun` | Mailgun API (Business+ tier) |
+| `ses` | Amazon SES (Business+ tier) |
+
+### ConversationType
+
+| Value | Description |
+|-------|-------------|
+| `admin_store` | Admin-store conversations |
+| `store_customer` | Store-customer conversations |
+| `admin_customer` | Admin-customer conversations |
+
+### ParticipantType
+
+| Value | Description |
+|-------|-------------|
+| `admin` | Platform admin user |
+| `store` | Store team user |
+| `customer` | Customer |
+
+## Design Patterns
+
+- **Template override system**: Platform templates + per-store overrides with language fallback
+- **Polymorphic participants**: Conversations support admin, store, and customer participants
+- **Email tracking**: Full lifecycle tracking (sent → delivered → opened → clicked)
+- **Provider abstraction**: Multiple email providers with per-store configuration
+- **Premium tier gating**: SendGrid, Mailgun, SES require Business+ tier
+- **Soft deletes**: Messages support soft delete with audit trail
diff --git a/app/modules/messaging/docs/email-settings-impl.md b/app/modules/messaging/docs/email-settings-impl.md
new file mode 100644
index 00000000..43bb0e2f
--- /dev/null
+++ b/app/modules/messaging/docs/email-settings-impl.md
@@ -0,0 +1,308 @@
+# Email Settings Implementation
+
+This document describes the technical implementation of the email settings system for both store and platform (admin) configurations.
+
+## Architecture Overview
+
+```
+┌─────────────────────────────────────────────────────────────────────────┐
+│ Email System Architecture │
+├─────────────────────────────────────────────────────────────────────────┤
+│ │
+│ ┌──────────────────┐ ┌──────────────────┐ │
+│ │ Platform Email │ │ Store Email │ │
+│ │ (Admin/Billing)│ │ (Customer-facing) │ │
+│ └────────┬─────────┘ └────────┬─────────┘ │
+│ │ │ │
+│ ▼ ▼ │
+│ ┌──────────────────┐ ┌──────────────────┐ │
+│ │ get_platform_ │ │ get_store_ │ │
+│ │ email_config(db) │ │ provider() │ │
+│ └────────┬─────────┘ └────────┬─────────┘ │
+│ │ │ │
+│ ▼ ▼ │
+│ ┌──────────────────┐ ┌──────────────────┐ │
+│ │ AdminSettings DB │ │StoreEmailSettings│ │
+│ │ (.env fallback)│ │ (per store) │ │
+│ └────────┬─────────┘ └────────┬─────────┘ │
+│ │ │ │
+│ └───────────┬───────────────┘ │
+│ ▼ │
+│ ┌──────────────────┐ │
+│ │ EmailService │ │
+│ │ send_raw() │ │
+│ └────────┬─────────┘ │
+│ │ │
+│ ▼ │
+│ ┌──────────────────┐ │
+│ │ Email Providers │ │
+│ │ SMTP/SG/MG/SES │ │
+│ └──────────────────┘ │
+└─────────────────────────────────────────────────────────────────────────┘
+```
+
+## Database Models
+
+### StoreEmailSettings
+
+```python
+# models/database/store_email_settings.py
+
+class StoreEmailSettings(Base):
+ __tablename__ = "store_email_settings"
+
+ id: int
+ store_id: int # FK to stores.id (one-to-one)
+
+ # Sender Identity
+ from_email: str
+ from_name: str
+ reply_to_email: str | None
+
+ # Signature
+ signature_text: str | None
+ signature_html: str | None
+
+ # Provider
+ provider: str = "smtp" # smtp, sendgrid, mailgun, ses
+
+ # SMTP Settings
+ smtp_host: str | None
+ smtp_port: int = 587
+ smtp_username: str | None
+ smtp_password: str | None
+ smtp_use_tls: bool = True
+ smtp_use_ssl: bool = False
+
+ # SendGrid
+ sendgrid_api_key: str | None
+
+ # Mailgun
+ mailgun_api_key: str | None
+ mailgun_domain: str | None
+
+ # SES
+ ses_access_key_id: str | None
+ ses_secret_access_key: str | None
+ ses_region: str = "eu-west-1"
+
+ # Status
+ is_configured: bool = False
+ is_verified: bool = False
+ last_verified_at: datetime | None
+ verification_error: str | None
+```
+
+### Admin Settings (Platform Email)
+
+Platform email settings are stored in the generic `admin_settings` table with category="email":
+
+```python
+# Keys stored in admin_settings table
+EMAIL_SETTING_KEYS = {
+ "email_provider",
+ "email_from_address",
+ "email_from_name",
+ "email_reply_to",
+ "smtp_host",
+ "smtp_port",
+ "smtp_user",
+ "smtp_password",
+ "smtp_use_tls",
+ "smtp_use_ssl",
+ "sendgrid_api_key",
+ "mailgun_api_key",
+ "mailgun_domain",
+ "aws_access_key_id",
+ "aws_secret_access_key",
+ "aws_region",
+ "email_enabled",
+ "email_debug",
+}
+```
+
+## API Endpoints
+
+### Store Email Settings
+
+| Endpoint | Method | Description |
+|----------|--------|-------------|
+| `/api/v1/store/email-settings` | GET | Get current email settings |
+| `/api/v1/store/email-settings` | PUT | Create/update email settings |
+| `/api/v1/store/email-settings` | DELETE | Delete email settings |
+| `/api/v1/store/email-settings/status` | GET | Get configuration status |
+| `/api/v1/store/email-settings/providers` | GET | Get available providers for tier |
+| `/api/v1/store/email-settings/verify` | POST | Send test email |
+
+### Admin Email Settings
+
+| Endpoint | Method | Description |
+|----------|--------|-------------|
+| `/api/v1/admin/settings/email/status` | GET | Get effective email config |
+| `/api/v1/admin/settings/email/settings` | PUT | Update email settings in DB |
+| `/api/v1/admin/settings/email/settings` | DELETE | Reset to .env defaults |
+| `/api/v1/admin/settings/email/test` | POST | Send test email |
+
+## Services
+
+### StoreEmailSettingsService
+
+Location: `app/services/store_email_settings_service.py`
+
+Key methods:
+- `get_settings(store_id)` - Get settings for a store
+- `create_or_update(store_id, data, current_tier)` - Create/update settings
+- `delete(store_id)` - Delete settings
+- `verify_settings(store_id, test_email)` - Send test email
+- `get_available_providers(tier)` - Get providers for subscription tier
+
+### EmailService Integration
+
+The EmailService (`app/services/email_service.py`) uses:
+
+1. **Platform Config**: `get_platform_email_config(db)` checks database first, then .env
+2. **Store Config**: `get_store_provider(settings)` creates provider from StoreEmailSettings
+3. **Provider Selection**: `send_raw()` uses store provider when `store_id` provided and `is_platform_email=False`
+
+```python
+# EmailService.send_raw() flow
+def send_raw(self, to_email, subject, body_html, store_id=None, is_platform_email=False):
+ if store_id and not is_platform_email:
+ # Use store's email provider
+ store_settings = self._get_store_email_settings(store_id)
+ if store_settings and store_settings.is_configured:
+ provider = get_store_provider(store_settings)
+ else:
+ # Use platform provider (DB config > .env)
+ provider = self.provider # Set in __init__ via get_platform_provider(db)
+```
+
+## Tier-Based Features
+
+### Premium Provider Gating
+
+Premium providers (SendGrid, Mailgun, SES) are gated to Business+ tiers:
+
+```python
+PREMIUM_EMAIL_PROVIDERS = {EmailProvider.SENDGRID, EmailProvider.MAILGUN, EmailProvider.SES}
+PREMIUM_TIERS = {TierCode.BUSINESS, TierCode.ENTERPRISE}
+
+def create_or_update(self, store_id, data, current_tier):
+ provider = data.get("provider", "smtp")
+ if provider in [p.value for p in PREMIUM_EMAIL_PROVIDERS]:
+ if current_tier not in PREMIUM_TIERS:
+ raise AuthorizationException(...)
+```
+
+### White-Label Branding
+
+Emails include "Powered by Orion" footer for non-whitelabel tiers:
+
+```python
+WHITELABEL_TIERS = {"business", "enterprise"}
+
+POWERED_BY_FOOTER_HTML = """
+
+
+ {% if invoice.vat_regime == 'reverse_charge' %}
+
+ Autoliquidation de la TVA
+ En application de l'article 196 de la directive 2006/112/CE, la TVA est due par le preneur.
+
+ {% elif invoice.vat_regime == 'oss' %}
+
+ Régime OSS (One-Stop-Shop)
+ TVA calculée selon le taux du pays de destination ({{ invoice.destination_country }}).
+
+ {% endif %}
+
+
+
", f"{POWERED_BY_FOOTER_HTML}")
+```
+
+## Configuration Priority
+
+### Platform Email
+
+1. **Database** (admin_settings table) - Highest priority
+2. **Environment Variables** (.env) - Fallback
+
+```python
+def get_platform_email_config(db: Session) -> dict:
+ def get_db_setting(key: str) -> str | None:
+ setting = db.query(AdminSetting).filter(AdminSetting.key == key).first()
+ return setting.value if setting else None
+
+ # Check DB first, fallback to .env
+ db_provider = get_db_setting("email_provider")
+ config["provider"] = db_provider if db_provider else settings.email_provider
+ ...
+```
+
+### Store Email
+
+Stores have their own dedicated settings table with no fallback - they must configure their own email.
+
+## Frontend Components
+
+### Store Settings Page
+
+- **Location**: `app/templates/store/settings.html`, `static/store/js/settings.js`
+- **Alpine.js State**: `emailSettings`, `emailForm`, `hasEmailChanges`
+- **Methods**: `loadEmailSettings()`, `saveEmailSettings()`, `sendTestEmail()`
+
+### Admin Settings Page
+
+- **Location**: `app/templates/admin/settings.html`, `static/admin/js/settings.js`
+- **Alpine.js State**: `emailSettings`, `emailForm`, `emailEditMode`
+- **Methods**: `loadEmailSettings()`, `saveEmailSettings()`, `resetEmailSettings()`, `sendTestEmail()`
+
+### Warning Banner
+
+Shows until email is configured:
+
+```html
+
+{% macro email_settings_warning() %}
+
+ Configure email settings to send emails to customers.
+
+{% endmacro %}
+```
+
+## Testing
+
+### Unit Tests
+
+Location: `tests/unit/services/test_store_email_settings_service.py`
+
+Tests:
+- Read operations (get_settings, get_status, is_configured)
+- Write operations (create_or_update, delete)
+- Tier validation (premium providers)
+- Verification (mock SMTP)
+- Provider availability
+
+### Integration Tests
+
+Locations:
+- `tests/integration/api/v1/store/test_email_settings.py`
+- `tests/integration/api/v1/admin/test_email_settings.py`
+
+Tests:
+- CRUD operations via API
+- Authentication/authorization
+- Validation errors
+- Status endpoints
+
+## Files Modified/Created
+
+### New Files
+- `models/database/store_email_settings.py` - Model
+- `alembic/versions/v0a1b2c3d4e5_add_store_email_settings.py` - Migration
+- `app/services/store_email_settings_service.py` - Service
+- `app/api/v1/store/email_settings.py` - API endpoints
+- `scripts/seed/install.py` - Installation wizard
+
+### Modified Files
+- `app/services/email_service.py` - Added platform config, store providers
+- `app/api/v1/admin/settings.py` - Added email endpoints
+- `app/templates/admin/settings.html` - Email tab
+- `app/templates/store/settings.html` - Email tab
+- `static/admin/js/settings.js` - Email JS
+- `static/store/js/settings.js` - Email JS
+- `static/store/js/init-alpine.js` - Warning banner component
diff --git a/app/modules/messaging/docs/email-settings.md b/app/modules/messaging/docs/email-settings.md
new file mode 100644
index 00000000..361acada
--- /dev/null
+++ b/app/modules/messaging/docs/email-settings.md
@@ -0,0 +1,254 @@
+# Email Settings Guide
+
+This guide covers email configuration for both **stores** and **platform administrators**. The Orion platform uses a layered email system where stores manage their own email sending while the platform handles system-level communications.
+
+## Overview
+
+The email system has two distinct configurations:
+
+| Aspect | Platform (Admin) | Store |
+|--------|-----------------|--------|
+| Purpose | System emails (billing, admin notifications) | Customer-facing emails (orders, marketing) |
+| Configuration | Environment variables (.env) + Database overrides | Database (per-store) |
+| Cost | Platform owner pays | Store pays |
+| Providers | SMTP, SendGrid, Mailgun, SES | SMTP (all tiers), Premium providers (Business+) |
+
+---
+
+## Store Email Settings
+
+### Getting Started
+
+As a store, you need to configure email settings to send emails to your customers. This includes order confirmations, shipping updates, and marketing emails.
+
+#### Accessing Email Settings
+
+1. Log in to your Store Dashboard
+2. Navigate to **Settings** from the sidebar
+3. Click on the **Email** tab
+
+### Available Providers
+
+| Provider | Tier Required | Best For |
+|----------|---------------|----------|
+| SMTP | All tiers | Standard email servers, most common |
+| SendGrid | Business+ | High-volume transactional emails |
+| Mailgun | Business+ | Developer-friendly API |
+| Amazon SES | Business+ | AWS ecosystem, cost-effective |
+
+### Configuring SMTP
+
+SMTP is available for all subscription tiers. Common SMTP providers include:
+- Gmail (smtp.gmail.com:587)
+- Microsoft 365 (smtp.office365.com:587)
+- Your hosting provider's SMTP server
+
+**Required Fields:**
+- **From Email**: The sender email address (e.g., orders@yourstore.com)
+- **From Name**: The sender display name (e.g., "Your Store")
+- **SMTP Host**: Your SMTP server address
+- **SMTP Port**: Usually 587 (TLS) or 465 (SSL)
+- **SMTP Username**: Your login username
+- **SMTP Password**: Your login password
+- **Use TLS**: Enable for port 587 (recommended)
+- **Use SSL**: Enable for port 465
+
+### Configuring Premium Providers (Business+)
+
+If you have a Business or Enterprise subscription, you can use premium email providers:
+
+#### SendGrid
+1. Create a SendGrid account at [sendgrid.com](https://sendgrid.com)
+2. Generate an API key
+3. Enter the API key in your store settings
+
+#### Mailgun
+1. Create a Mailgun account at [mailgun.com](https://mailgun.com)
+2. Add and verify your domain
+3. Get your API key from the dashboard
+4. Enter the API key and domain in your settings
+
+#### Amazon SES
+1. Set up SES in your AWS account
+2. Verify your sender domain/email
+3. Create IAM credentials with SES permissions
+4. Enter the access key, secret key, and region
+
+### Verifying Your Configuration
+
+After configuring your email settings:
+
+1. Click **Save Settings**
+2. Enter a test email address in the **Test Email** field
+3. Click **Send Test**
+4. Check your inbox for the test email
+
+If the test fails, check:
+- Your credentials are correct
+- Your IP/domain is not blocked
+- For Gmail: Allow "less secure apps" or use an app password
+
+### Email Warning Banner
+
+Until you configure and verify your email settings, you'll see a warning banner at the top of your dashboard. This ensures you don't forget to set up email before your store goes live.
+
+---
+
+## Platform Admin Email Settings
+
+### Overview
+
+Platform administrators can configure system-wide email settings for platform communications like:
+- Subscription billing notifications
+- Admin alerts
+- Platform-wide announcements
+
+### Configuration Sources
+
+Admin email settings support two configuration sources:
+
+1. **Environment Variables (.env)** - Default configuration
+2. **Database Overrides** - Override .env via the admin UI
+
+Database settings take priority over .env values.
+
+### Accessing Admin Email Settings
+
+1. Log in to the Admin Panel
+2. Navigate to **Settings**
+3. Click on the **Email** tab
+
+### Viewing Current Configuration
+
+The Email tab shows:
+- **Provider**: Current email provider (SMTP, SendGrid, etc.)
+- **From Email**: Sender email address
+- **From Name**: Sender display name
+- **Status**: Whether email is configured and enabled
+- **DB Overrides**: Whether database overrides are active
+
+### Editing Settings
+
+Click **Edit Settings** to modify the email configuration:
+
+1. Select the email provider
+2. Enter the required credentials
+3. Configure enabled/debug flags
+4. Click **Save Email Settings**
+
+### Resetting to .env Defaults
+
+If you've made database overrides and want to revert to .env configuration:
+
+1. Click **Reset to .env Defaults**
+2. Confirm the action
+
+This removes all email settings from the database, reverting to .env values.
+
+### Testing Configuration
+
+1. Enter a test email address
+2. Click **Send Test**
+3. Check your inbox
+
+---
+
+## Environment Variables Reference
+
+For platform configuration via .env:
+
+```env
+# Provider: smtp, sendgrid, mailgun, ses
+EMAIL_PROVIDER=smtp
+
+# Sender identity
+EMAIL_FROM_ADDRESS=noreply@yourplatform.com
+EMAIL_FROM_NAME=Your Platform
+EMAIL_REPLY_TO=support@yourplatform.com
+
+# Behavior
+EMAIL_ENABLED=true
+EMAIL_DEBUG=false
+
+# SMTP Configuration
+SMTP_HOST=smtp.example.com
+SMTP_PORT=587
+SMTP_USER=your-username
+SMTP_PASSWORD=your-password
+SMTP_USE_TLS=true
+SMTP_USE_SSL=false
+
+# SendGrid
+SENDGRID_API_KEY=your-api-key
+
+# Mailgun
+MAILGUN_API_KEY=your-api-key
+MAILGUN_DOMAIN=mg.yourdomain.com
+
+# Amazon SES
+AWS_ACCESS_KEY_ID=your-access-key
+AWS_SECRET_ACCESS_KEY=your-secret-key
+AWS_REGION=eu-west-1
+```
+
+---
+
+## Tier-Based Branding
+
+The email system includes tier-based branding for store emails:
+
+| Tier | Branding |
+|------|----------|
+| Essential | "Powered by Orion" footer |
+| Professional | "Powered by Orion" footer |
+| Business | No branding (white-label) |
+| Enterprise | No branding (white-label) |
+
+Business and Enterprise tier stores get completely white-labeled emails with no Orion branding.
+
+---
+
+## Troubleshooting
+
+### Common Issues
+
+**"Email sending is disabled"**
+- Check that `EMAIL_ENABLED=true` in .env
+- Or enable it in the admin settings
+
+**"Connection refused" on SMTP**
+- Verify SMTP host and port
+- Check firewall rules
+- Ensure TLS/SSL settings match your server
+
+**"Authentication failed"**
+- Double-check username/password
+- For Gmail, use an App Password
+- For Microsoft 365, check MFA requirements
+
+**"SendGrid error: 403"**
+- Verify your API key has Mail Send permissions
+- Check sender identity is verified
+
+**Premium provider not available**
+- Upgrade to Business or Enterprise tier
+- Contact support if you have the right tier but can't access
+
+### Debug Mode
+
+Enable debug mode to log emails instead of sending them:
+- Set `EMAIL_DEBUG=true` in .env
+- Or enable "Debug mode" in admin settings
+
+Debug mode logs the email content to the server logs without actually sending.
+
+---
+
+## Security Best Practices
+
+1. **Never share API keys or passwords** in logs or frontend
+2. **Use environment variables** for sensitive credentials
+3. **Enable TLS** for SMTP connections
+4. **Verify sender domains** with your email provider
+5. **Monitor email logs** for delivery issues
+6. **Rotate credentials** periodically
diff --git a/app/modules/messaging/docs/email-system.md b/app/modules/messaging/docs/email-system.md
new file mode 100644
index 00000000..fe6cbe00
--- /dev/null
+++ b/app/modules/messaging/docs/email-system.md
@@ -0,0 +1,331 @@
+# 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="