From f141cc4e6a61b2131872849f7619fb755d11f4a8 Mon Sep 17 00:00:00 2001
From: Samir Boulahtit
Date: Sun, 8 Mar 2026 23:38:37 +0100
Subject: [PATCH] docs: migrate module documentation to single source of truth
Move 39 documentation files from top-level docs/ into each module's
docs/ folder, accessible via symlinks from docs/modules/. Create
data-model.md files for 10 modules with full schema documentation.
Replace originals with redirect stubs. Remove empty guide stubs.
Modules migrated: tenancy, billing, loyalty, marketplace, orders,
messaging, cms, catalog, inventory, hosting, prospecting.
Co-Authored-By: Claude Opus 4.6
---
app/modules/analytics/docs/index.md | 42 +
app/modules/billing/docs/data-model.md | 138 ++
app/modules/billing/docs/feature-gating.md | 434 ++++++
app/modules/billing/docs/index.md | 74 +
.../billing/docs/stripe-integration.md | 617 ++++++++
.../billing/docs/subscription-system.md | 182 +++
.../billing/docs/subscription-workflow.md | 454 ++++++
app/modules/billing/docs/tier-management.md | 135 ++
app/modules/cart/docs/index.md | 41 +
app/modules/catalog/docs/architecture.md | 291 ++++
app/modules/catalog/docs/data-model.md | 105 ++
app/modules/catalog/docs/index.md | 57 +
app/modules/checkout/docs/index.md | 41 +
app/modules/cms/docs/architecture.md | 604 ++++++++
app/modules/cms/docs/data-model.md | 115 ++
app/modules/cms/docs/email-templates-guide.md | 287 ++++
app/modules/cms/docs/email-templates.md | 458 ++++++
app/modules/cms/docs/implementation.md | 414 +++++
app/modules/cms/docs/index.md | 61 +
app/modules/cms/docs/media-library.md | 182 +++
app/modules/contracts/docs/index.md | 33 +
app/modules/core/docs/index.md | 44 +
app/modules/customers/docs/index.md | 47 +
app/modules/dev_tools/docs/index.md | 42 +
app/modules/hosting/docs/index.md | 49 +
app/modules/hosting/docs/user-journeys.md | 502 ++++++
app/modules/inventory/docs/data-model.md | 82 +
app/modules/inventory/docs/index.md | 53 +
app/modules/inventory/docs/user-guide.md | 366 +++++
app/modules/loyalty/docs/business-logic.md | 264 ++++
app/modules/loyalty/docs/data-model.md | 235 +++
app/modules/loyalty/docs/index.md | 110 ++
app/modules/loyalty/docs/program-analysis.md | 387 +++++
app/modules/loyalty/docs/ui-design.md | 670 ++++++++
app/modules/loyalty/docs/user-journeys.md | 794 ++++++++++
app/modules/marketplace/docs/admin-guide.md | 261 ++++
app/modules/marketplace/docs/api.md | 322 ++++
app/modules/marketplace/docs/architecture.md | 1345 +++++++++++++++++
app/modules/marketplace/docs/data-model.md | 297 ++++
.../marketplace/docs/import-improvements.md | 601 ++++++++
app/modules/marketplace/docs/index.md | 73 +
.../marketplace/docs/integration-guide.md | Bin 0 -> 19288 bytes
app/modules/marketplace/docs/job-queue.md | 716 +++++++++
.../marketplace/docs/order-integration.md | 839 ++++++++++
app/modules/messaging/docs/architecture.md | 243 +++
app/modules/messaging/docs/data-model.md | 290 ++++
.../messaging/docs/email-settings-impl.md | 308 ++++
app/modules/messaging/docs/email-settings.md | 254 ++++
app/modules/messaging/docs/email-system.md | 331 ++++
app/modules/messaging/docs/index.md | 63 +
app/modules/messaging/docs/notifications.md | 187 +++
app/modules/monitoring/docs/index.md | 44 +
app/modules/orders/docs/architecture.md | 345 +++++
app/modules/orders/docs/data-model.md | 229 +++
app/modules/orders/docs/exceptions.md | 288 ++++
app/modules/orders/docs/index.md | 67 +
app/modules/orders/docs/oms-features.md | 662 ++++++++
app/modules/orders/docs/stock-integration.md | 371 +++++
app/modules/orders/docs/unified-order-view.md | 275 ++++
app/modules/orders/docs/vat-invoicing.md | 734 +++++++++
app/modules/payments/docs/index.md | 46 +
app/modules/prospecting/docs/index.md | 58 +
app/modules/prospecting/docs/user-journeys.md | 435 ++++++
app/modules/tenancy/docs/data-model.md | 160 ++
app/modules/tenancy/docs/index.md | 64 +
app/modules/tenancy/docs/migration.md | 243 +++
app/modules/tenancy/docs/onboarding.md | 191 +++
app/modules/tenancy/docs/rbac.md | 686 +++++++++
.../customer-orders-architecture.md | 346 +----
docs/architecture/marketplace-integration.md | 1344 +---------------
docs/architecture/module-system.md | 5 +
docs/architecture/product-architecture.md | 292 +---
docs/architecture/tenancy-module-migration.md | 285 +---
docs/backend/store-rbac.md | 845 +----------
docs/deployment/stripe-integration.md | 618 +-------
docs/development/creating-modules.md | 9 +-
docs/development/module-documentation.md | 82 +
docs/features/cms-implementation-guide.md | 415 +----
docs/features/content-management-system.md | 605 +-------
docs/features/email-system.md | 332 +---
docs/features/store-onboarding.md | 190 +--
docs/features/subscription-billing.md | 630 +-------
docs/features/user-journeys/hosting.md | 503 +-----
docs/features/user-journeys/loyalty.md | 793 +---------
docs/features/user-journeys/prospecting.md | 436 +-----
docs/getting-started/database-setup.md | 1 -
docs/guides/csv-import.md | 0
docs/guides/email-settings.md | 255 +---
docs/guides/email-templates.md | 288 +---
docs/guides/inventory-management.md | 367 +----
docs/guides/letzshop-admin-management.md | 260 +---
docs/guides/letzshop-marketplace-api.md | 321 +---
docs/guides/letzshop-order-integration.md | 838 +---------
docs/guides/marketplace-integration.md | Bin 19285 -> 154 bytes
docs/guides/media-library.md | 183 +--
docs/guides/product-management.md | 0
docs/guides/shop-setup.md | 0
docs/guides/subscription-tier-management.md | 134 +-
.../admin-notification-system.md | 188 +--
docs/implementation/email-settings.md | 309 +---
.../email-templates-architecture.md | 459 +-----
docs/implementation/feature-gating-system.md | 433 +-----
.../letzshop-jobs-improvements.md | 715 +--------
.../letzshop-order-import-improvements.md | 600 +-------
docs/implementation/messaging-system.md | 244 +--
docs/implementation/oms-feature-plan.md | 663 +-------
docs/implementation/order-item-exceptions.md | 289 +---
.../stock-management-integration.md | 372 +----
.../subscription-workflow-plan.md | 453 +-----
docs/implementation/unified-order-view.md | 276 +---
docs/implementation/vat-invoice-feature.md | 735 +--------
docs/index.md | 3 -
docs/modules/analytics | 1 +
docs/modules/billing | 1 +
docs/modules/cart | 1 +
docs/modules/catalog | 1 +
docs/modules/checkout | 1 +
docs/modules/cms | 1 +
docs/modules/contracts | 1 +
docs/modules/core | 1 +
docs/modules/customers | 1 +
docs/modules/dev_tools | 1 +
docs/modules/hosting | 1 +
docs/modules/inventory | 1 +
docs/modules/loyalty | 1 +
docs/modules/loyalty.md | 321 ----
docs/modules/marketplace | 1 +
docs/modules/messaging | 1 +
docs/modules/monitoring | 1 +
docs/modules/orders | 1 +
docs/modules/payments | 1 +
docs/modules/prospecting | 1 +
docs/modules/prospecting/database.md | 171 ---
docs/modules/prospecting/research-findings.md | 80 -
docs/modules/prospecting/scoring.md | 110 --
docs/modules/tenancy | 1 +
.../loyalty-phase2-interfaces-plan.md | 669 +-------
docs/proposals/loyalty-program-analysis.md | 386 +----
.../module-documentation-migration-plan.md | 242 +++
mkdocs.yml | 91 +-
140 files changed, 19921 insertions(+), 17723 deletions(-)
create mode 100644 app/modules/analytics/docs/index.md
create mode 100644 app/modules/billing/docs/data-model.md
create mode 100644 app/modules/billing/docs/feature-gating.md
create mode 100644 app/modules/billing/docs/index.md
create mode 100644 app/modules/billing/docs/stripe-integration.md
create mode 100644 app/modules/billing/docs/subscription-system.md
create mode 100644 app/modules/billing/docs/subscription-workflow.md
create mode 100644 app/modules/billing/docs/tier-management.md
create mode 100644 app/modules/cart/docs/index.md
create mode 100644 app/modules/catalog/docs/architecture.md
create mode 100644 app/modules/catalog/docs/data-model.md
create mode 100644 app/modules/catalog/docs/index.md
create mode 100644 app/modules/checkout/docs/index.md
create mode 100644 app/modules/cms/docs/architecture.md
create mode 100644 app/modules/cms/docs/data-model.md
create mode 100644 app/modules/cms/docs/email-templates-guide.md
create mode 100644 app/modules/cms/docs/email-templates.md
create mode 100644 app/modules/cms/docs/implementation.md
create mode 100644 app/modules/cms/docs/index.md
create mode 100644 app/modules/cms/docs/media-library.md
create mode 100644 app/modules/contracts/docs/index.md
create mode 100644 app/modules/core/docs/index.md
create mode 100644 app/modules/customers/docs/index.md
create mode 100644 app/modules/dev_tools/docs/index.md
create mode 100644 app/modules/hosting/docs/index.md
create mode 100644 app/modules/hosting/docs/user-journeys.md
create mode 100644 app/modules/inventory/docs/data-model.md
create mode 100644 app/modules/inventory/docs/index.md
create mode 100644 app/modules/inventory/docs/user-guide.md
create mode 100644 app/modules/loyalty/docs/business-logic.md
create mode 100644 app/modules/loyalty/docs/data-model.md
create mode 100644 app/modules/loyalty/docs/index.md
create mode 100644 app/modules/loyalty/docs/program-analysis.md
create mode 100644 app/modules/loyalty/docs/ui-design.md
create mode 100644 app/modules/loyalty/docs/user-journeys.md
create mode 100644 app/modules/marketplace/docs/admin-guide.md
create mode 100644 app/modules/marketplace/docs/api.md
create mode 100644 app/modules/marketplace/docs/architecture.md
create mode 100644 app/modules/marketplace/docs/data-model.md
create mode 100644 app/modules/marketplace/docs/import-improvements.md
create mode 100644 app/modules/marketplace/docs/index.md
create mode 100644 app/modules/marketplace/docs/integration-guide.md
create mode 100644 app/modules/marketplace/docs/job-queue.md
create mode 100644 app/modules/marketplace/docs/order-integration.md
create mode 100644 app/modules/messaging/docs/architecture.md
create mode 100644 app/modules/messaging/docs/data-model.md
create mode 100644 app/modules/messaging/docs/email-settings-impl.md
create mode 100644 app/modules/messaging/docs/email-settings.md
create mode 100644 app/modules/messaging/docs/email-system.md
create mode 100644 app/modules/messaging/docs/index.md
create mode 100644 app/modules/messaging/docs/notifications.md
create mode 100644 app/modules/monitoring/docs/index.md
create mode 100644 app/modules/orders/docs/architecture.md
create mode 100644 app/modules/orders/docs/data-model.md
create mode 100644 app/modules/orders/docs/exceptions.md
create mode 100644 app/modules/orders/docs/index.md
create mode 100644 app/modules/orders/docs/oms-features.md
create mode 100644 app/modules/orders/docs/stock-integration.md
create mode 100644 app/modules/orders/docs/unified-order-view.md
create mode 100644 app/modules/orders/docs/vat-invoicing.md
create mode 100644 app/modules/payments/docs/index.md
create mode 100644 app/modules/prospecting/docs/index.md
create mode 100644 app/modules/prospecting/docs/user-journeys.md
create mode 100644 app/modules/tenancy/docs/data-model.md
create mode 100644 app/modules/tenancy/docs/index.md
create mode 100644 app/modules/tenancy/docs/migration.md
create mode 100644 app/modules/tenancy/docs/onboarding.md
create mode 100644 app/modules/tenancy/docs/rbac.md
create mode 100644 docs/development/module-documentation.md
delete mode 100644 docs/guides/csv-import.md
delete mode 100644 docs/guides/product-management.md
delete mode 100644 docs/guides/shop-setup.md
create mode 120000 docs/modules/analytics
create mode 120000 docs/modules/billing
create mode 120000 docs/modules/cart
create mode 120000 docs/modules/catalog
create mode 120000 docs/modules/checkout
create mode 120000 docs/modules/cms
create mode 120000 docs/modules/contracts
create mode 120000 docs/modules/core
create mode 120000 docs/modules/customers
create mode 120000 docs/modules/dev_tools
create mode 120000 docs/modules/hosting
create mode 120000 docs/modules/inventory
create mode 120000 docs/modules/loyalty
delete mode 100644 docs/modules/loyalty.md
create mode 120000 docs/modules/marketplace
create mode 120000 docs/modules/messaging
create mode 120000 docs/modules/monitoring
create mode 120000 docs/modules/orders
create mode 120000 docs/modules/payments
create mode 120000 docs/modules/prospecting
delete mode 100644 docs/modules/prospecting/database.md
delete mode 100644 docs/modules/prospecting/research-findings.md
delete mode 100644 docs/modules/prospecting/scoring.md
create mode 120000 docs/modules/tenancy
create mode 100644 docs/proposals/module-documentation-migration-plan.md
diff --git a/app/modules/analytics/docs/index.md b/app/modules/analytics/docs/index.md
new file mode 100644
index 00000000..eea9c2cb
--- /dev/null
+++ b/app/modules/analytics/docs/index.md
@@ -0,0 +1,42 @@
+# Analytics & Reporting
+
+Dashboard analytics, custom reports, and data exports.
+
+## Overview
+
+| Aspect | Detail |
+|--------|--------|
+| Code | `analytics` |
+| Classification | Optional |
+| Dependencies | `catalog`, `inventory`, `marketplace`, `orders` |
+| Status | Active |
+
+## Features
+
+- `basic_reports` — Standard built-in reports
+- `analytics_dashboard` — Analytics dashboard widgets
+- `custom_reports` — Custom report builder
+- `export_reports` — Report data export
+- `usage_metrics` — Platform usage metrics
+
+## Permissions
+
+| Permission | Description |
+|------------|-------------|
+| `analytics.view` | View analytics and reports |
+| `analytics.export` | Export report data |
+| `analytics.manage_dashboards` | Create/edit custom dashboards |
+
+## Data Model
+
+Analytics primarily queries data from other modules (orders, inventory, catalog).
+
+## API Endpoints
+
+| Method | Path | Description |
+|--------|------|-------------|
+| `GET` | `/api/v1/store/analytics/*` | Store analytics data |
+
+## Configuration
+
+No module-specific configuration.
diff --git a/app/modules/billing/docs/data-model.md b/app/modules/billing/docs/data-model.md
new file mode 100644
index 00000000..444154cc
--- /dev/null
+++ b/app/modules/billing/docs/data-model.md
@@ -0,0 +1,138 @@
+# Billing Data Model
+
+## Entity Relationship Overview
+
+```
+┌───────────────────┐
+│ SubscriptionTier │
+└────────┬──────────┘
+ │ 1:N
+ ▼
+┌───────────────────┐ ┌──────────────────────┐
+│ TierFeatureLimit │ │ MerchantSubscription │
+│ (feature limits) │ │ (per merchant+plat) │
+└───────────────────┘ └──────────┬───────────┘
+ │
+ ┌──────────┼──────────────┐
+ ▼ ▼ ▼
+ ┌────────────┐ ┌──────────┐ ┌─────────────┐
+ │ BillingHist│ │StoreAddOn│ │FeatureOverride│
+ └────────────┘ └──────────┘ └─────────────┘
+ │
+ ▼
+ ┌────────────┐
+ │AddOnProduct│
+ └────────────┘
+
+┌──────────────────────┐
+│StripeWebhookEvent │ (idempotency tracking)
+└──────────────────────┘
+```
+
+## Core Entities
+
+### SubscriptionTier
+
+Defines available subscription plans with pricing and Stripe integration.
+
+| Column | Type | Description |
+|--------|------|-------------|
+| `id` | Integer | Primary key |
+| `code` | String | Unique tier code (`essential`, `professional`, `business`, `enterprise`) |
+| `name` | String | Display name |
+| `price_monthly_cents` | Integer | Monthly price in cents |
+| `price_annual_cents` | Integer | Annual price in cents (optional) |
+| `stripe_product_id` | String | Stripe product ID |
+| `stripe_price_monthly_id` | String | Stripe monthly price ID |
+| `stripe_price_annual_id` | String | Stripe annual price ID |
+| `display_order` | Integer | Sort order on pricing pages |
+| `is_active` | Boolean | Available for subscription |
+| `is_public` | Boolean | Visible to stores |
+
+### TierFeatureLimit
+
+Per-tier feature limits — each row links a tier to a feature code with a limit value.
+
+| Column | Type | Description |
+|--------|------|-------------|
+| `id` | Integer | Primary key |
+| `tier_id` | Integer | FK to SubscriptionTier |
+| `feature_code` | String | Feature identifier (e.g., `max_products`) |
+| `limit_value` | Integer | Numeric limit (NULL = unlimited) |
+| `enabled` | Boolean | Whether feature is enabled for this tier |
+
+### MerchantSubscription
+
+Per-merchant+platform subscription state. Subscriptions are merchant-level, not store-level.
+
+| Column | Type | Description |
+|--------|------|-------------|
+| `id` | Integer | Primary key |
+| `merchant_id` | Integer | FK to Merchant |
+| `platform_id` | Integer | FK to Platform |
+| `tier_id` | Integer | FK to SubscriptionTier |
+| `tier_code` | String | Tier code (denormalized for convenience) |
+| `status` | SubscriptionStatus | `trial`, `active`, `past_due`, `cancelled`, `expired` |
+| `stripe_customer_id` | String | Stripe customer ID |
+| `stripe_subscription_id` | String | Stripe subscription ID |
+| `trial_ends_at` | DateTime | Trial expiry |
+| `period_start` | DateTime | Current billing period start |
+| `period_end` | DateTime | Current billing period end |
+
+### MerchantFeatureOverride
+
+Per-merchant exceptions to tier defaults (e.g., enterprise custom limits).
+
+| Column | Type | Description |
+|--------|------|-------------|
+| `id` | Integer | Primary key |
+| `merchant_id` | Integer | FK to Merchant |
+| `feature_code` | String | Feature identifier |
+| `limit_value` | Integer | Override limit (NULL = unlimited) |
+
+## Add-on Entities
+
+### AddOnProduct
+
+Purchasable add-on items (domains, SSL, email packages).
+
+| Column | Type | Description |
+|--------|------|-------------|
+| `id` | Integer | Primary key |
+| `code` | String | Unique add-on code |
+| `name` | String | Display name |
+| `category` | AddOnCategory | `domain`, `ssl`, `email` |
+| `price_cents` | Integer | Price in cents |
+| `billing_period` | BillingPeriod | `monthly` or `yearly` |
+
+### StoreAddOn
+
+Add-ons purchased by individual stores.
+
+| Column | Type | Description |
+|--------|------|-------------|
+| `id` | Integer | Primary key |
+| `store_id` | Integer | FK to Store |
+| `addon_product_id` | Integer | FK to AddOnProduct |
+| `config` | JSON | Configuration (e.g., domain name) |
+| `stripe_subscription_item_id` | String | Stripe subscription item ID |
+| `status` | String | `active`, `cancelled`, `pending_setup` |
+
+## Supporting Entities
+
+### BillingHistory
+
+Invoice and payment history records.
+
+### StripeWebhookEvent
+
+Idempotency tracking for Stripe webhook events. Prevents duplicate event processing.
+
+## Key Relationships
+
+- A **SubscriptionTier** has many **TierFeatureLimits** (one per feature)
+- A **Merchant** has one **MerchantSubscription** per Platform
+- A **MerchantSubscription** references one **SubscriptionTier**
+- A **Merchant** can have many **MerchantFeatureOverrides** (per-feature)
+- A **Store** can purchase many **StoreAddOns**
+- Feature limits are resolved: MerchantFeatureOverride > TierFeatureLimit > default
diff --git a/app/modules/billing/docs/feature-gating.md b/app/modules/billing/docs/feature-gating.md
new file mode 100644
index 00000000..ff8414ae
--- /dev/null
+++ b/app/modules/billing/docs/feature-gating.md
@@ -0,0 +1,434 @@
+# Feature Gating System
+
+## Overview
+
+The feature gating system provides tier-based access control for platform features. It allows restricting functionality based on store subscription tiers (Essential, Professional, Business, Enterprise) with contextual upgrade prompts when features are locked.
+
+**Implemented:** December 31, 2025
+
+## Architecture
+
+### Database Models
+
+Located in `models/database/feature.py`:
+
+| Model | Purpose |
+|-------|---------|
+| `Feature` | Feature definitions with tier requirements |
+| `StoreFeatureOverride` | Per-store feature overrides (enable/disable) |
+
+### Feature Model Structure
+
+```python
+class Feature(Base):
+ __tablename__ = "features"
+
+ id: int # Primary key
+ code: str # Unique feature code (e.g., "analytics_dashboard")
+ name: str # Display name
+ description: str # User-facing description
+ category: str # Feature category
+ minimum_tier_code: str # Minimum tier required (essential/professional/business/enterprise)
+ minimum_tier_order: int # Tier order for comparison (1-4)
+ is_active: bool # Whether feature is available
+ created_at: datetime
+ updated_at: datetime
+```
+
+### Tier Ordering
+
+| Tier | Order | Code |
+|------|-------|------|
+| Essential | 1 | `essential` |
+| Professional | 2 | `professional` |
+| Business | 3 | `business` |
+| Enterprise | 4 | `enterprise` |
+
+## Feature Categories
+
+30 features organized into 8 categories:
+
+### 1. Analytics
+| Feature Code | Name | Min Tier |
+|-------------|------|----------|
+| `basic_analytics` | Basic Analytics | Essential |
+| `analytics_dashboard` | Analytics Dashboard | Professional |
+| `advanced_analytics` | Advanced Analytics | Business |
+| `custom_reports` | Custom Reports | Enterprise |
+
+### 2. Product Management
+| Feature Code | Name | Min Tier |
+|-------------|------|----------|
+| `basic_products` | Product Management | Essential |
+| `bulk_product_edit` | Bulk Product Edit | Professional |
+| `product_variants` | Product Variants | Professional |
+| `product_bundles` | Product Bundles | Business |
+| `inventory_alerts` | Inventory Alerts | Professional |
+
+### 3. Order Management
+| Feature Code | Name | Min Tier |
+|-------------|------|----------|
+| `basic_orders` | Order Management | Essential |
+| `order_automation` | Order Automation | Professional |
+| `advanced_fulfillment` | Advanced Fulfillment | Business |
+| `multi_warehouse` | Multi-Warehouse | Enterprise |
+
+### 4. Marketing
+| Feature Code | Name | Min Tier |
+|-------------|------|----------|
+| `discount_codes` | Discount Codes | Professional |
+| `abandoned_cart` | Abandoned Cart Recovery | Business |
+| `email_marketing` | Email Marketing | Business |
+| `loyalty_program` | Loyalty Program | Enterprise |
+
+### 5. Support
+| Feature Code | Name | Min Tier |
+|-------------|------|----------|
+| `basic_support` | Email Support | Essential |
+| `priority_support` | Priority Support | Professional |
+| `phone_support` | Phone Support | Business |
+| `dedicated_manager` | Dedicated Account Manager | Enterprise |
+
+### 6. Integration
+| Feature Code | Name | Min Tier |
+|-------------|------|----------|
+| `basic_api` | Basic API Access | Professional |
+| `advanced_api` | Advanced API Access | Business |
+| `webhooks` | Webhooks | Business |
+| `custom_integrations` | Custom Integrations | Enterprise |
+
+### 7. Branding
+| Feature Code | Name | Min Tier |
+|-------------|------|----------|
+| `basic_theme` | Theme Customization | Essential |
+| `custom_domain` | Custom Domain | Professional |
+| `white_label` | White Label | Enterprise |
+| `custom_checkout` | Custom Checkout | Enterprise |
+
+### 8. Team
+| Feature Code | Name | Min Tier |
+|-------------|------|----------|
+| `team_management` | Team Management | Professional |
+| `role_permissions` | Role Permissions | Business |
+| `audit_logs` | Audit Logs | Business |
+
+## Services
+
+### FeatureService
+
+Located in `app/services/feature_service.py`:
+
+```python
+class FeatureService:
+ """Service for managing tier-based feature access."""
+
+ # In-memory caching (refreshed every 5 minutes)
+ _feature_cache: dict[str, Feature] = {}
+ _cache_timestamp: datetime | None = None
+ CACHE_TTL_SECONDS = 300
+
+ def has_feature(self, db: Session, store_id: int, feature_code: str) -> bool:
+ """Check if store has access to a feature."""
+
+ def get_available_features(self, db: Session, store_id: int) -> list[str]:
+ """Get list of feature codes available to store."""
+
+ def get_all_features_with_status(self, db: Session, store_id: int) -> list[dict]:
+ """Get all features with availability status for store."""
+
+ def get_feature_info(self, db: Session, feature_code: str) -> dict | None:
+ """Get full feature information including tier requirements."""
+```
+
+### UsageService
+
+Located in `app/services/usage_service.py`:
+
+```python
+class UsageService:
+ """Service for tracking and managing store usage against tier limits."""
+
+ def get_usage_summary(self, db: Session, store_id: int) -> dict:
+ """Get comprehensive usage summary with limits and upgrade info."""
+
+ def check_limit(self, db: Session, store_id: int, limit_type: str) -> dict:
+ """Check specific limit with detailed info."""
+
+ def get_upgrade_info(self, db: Session, store_id: int) -> dict:
+ """Get upgrade recommendations based on current usage."""
+```
+
+## Backend Enforcement
+
+### Decorator Pattern
+
+```python
+from app.core.feature_gate import require_feature
+
+@router.get("/analytics/advanced")
+@require_feature("advanced_analytics")
+async def get_advanced_analytics(
+ db: Session = Depends(get_db),
+ store_id: int = Depends(get_current_store_id)
+):
+ # Only accessible if store has advanced_analytics feature
+ pass
+```
+
+### Dependency Pattern
+
+```python
+from app.core.feature_gate import RequireFeature
+
+@router.get("/marketing/loyalty")
+async def get_loyalty_program(
+ db: Session = Depends(get_db),
+ _: None = Depends(RequireFeature("loyalty_program"))
+):
+ # Only accessible if store has loyalty_program feature
+ pass
+```
+
+### Exception Handling
+
+When a feature is not available, `FeatureNotAvailableException` is raised:
+
+```python
+class FeatureNotAvailableException(Exception):
+ def __init__(self, feature_code: str, required_tier: str):
+ self.feature_code = feature_code
+ self.required_tier = required_tier
+ super().__init__(f"Feature '{feature_code}' requires {required_tier} tier")
+```
+
+HTTP Response (403):
+```json
+{
+ "detail": "Feature 'advanced_analytics' requires Professional tier or higher",
+ "feature_code": "advanced_analytics",
+ "required_tier": "Professional",
+ "upgrade_url": "/store/orion/billing"
+}
+```
+
+## API Endpoints
+
+### Store Features API
+
+Base: `/api/v1/store/features`
+
+| Endpoint | Method | Description |
+|----------|--------|-------------|
+| `/features/available` | GET | List available feature codes |
+| `/features` | GET | All features with availability status |
+| `/features/{code}` | GET | Single feature info |
+| `/features/{code}/check` | GET | Quick availability check |
+
+### Store Usage API
+
+Base: `/api/v1/store/usage`
+
+| Endpoint | Method | Description |
+|----------|--------|-------------|
+| `/usage` | GET | Full usage summary with limits |
+| `/usage/check/{limit_type}` | GET | Check specific limit (orders/products/team_members) |
+| `/usage/upgrade-info` | GET | Upgrade recommendations |
+
+### Admin Features API
+
+Base: `/api/v1/admin/features`
+
+| Endpoint | Method | Description |
+|----------|--------|-------------|
+| `/features` | GET | List all features |
+| `/features/{id}` | GET | Get feature details |
+| `/features/{id}` | PUT | Update feature |
+| `/features/{id}/toggle` | POST | Toggle feature active status |
+| `/features/stores/{store_id}/overrides` | GET | Get store overrides |
+| `/features/stores/{store_id}/overrides` | POST | Create override |
+
+## Frontend Integration
+
+### Alpine.js Feature Store
+
+Located in `static/shared/js/feature-store.js`:
+
+```javascript
+// Usage in templates
+$store.features.has('analytics_dashboard') // Check feature
+$store.features.loaded // Loading state
+$store.features.getFeature('advanced_api') // Get feature details
+```
+
+### Alpine.js Upgrade Store
+
+Located in `static/shared/js/upgrade-prompts.js`:
+
+```javascript
+// Usage in templates
+$store.upgrade.shouldShowLimitWarning('orders')
+$store.upgrade.getUsageString('products')
+$store.upgrade.hasUpgradeRecommendation
+```
+
+### Jinja2 Macros
+
+Located in `app/templates/shared/macros/feature_gate.html`:
+
+#### Feature Gate Container
+```jinja2
+{% from "shared/macros/feature_gate.html" import feature_gate %}
+
+{% call feature_gate("analytics_dashboard") %}
+ Analytics content here - only visible if feature available
+{% endcall %}
+```
+
+#### Feature Locked Card
+```jinja2
+{% from "shared/macros/feature_gate.html" import feature_locked %}
+
+{{ feature_locked("advanced_analytics", "Advanced Analytics", "Get deeper insights") }}
+```
+
+#### Upgrade Banner
+```jinja2
+{% from "shared/macros/feature_gate.html" import upgrade_banner %}
+
+{{ upgrade_banner("custom_domain") }}
+```
+
+#### Usage Limit Warning
+```jinja2
+{% from "shared/macros/feature_gate.html" import limit_warning %}
+
+{{ limit_warning("orders") }} {# Shows warning when approaching limit #}
+```
+
+#### Usage Progress Bar
+```jinja2
+{% from "shared/macros/feature_gate.html" import usage_bar %}
+
+{{ usage_bar("products", "Products") }}
+```
+
+#### Tier Badge
+```jinja2
+{% from "shared/macros/feature_gate.html" import tier_badge %}
+
+{{ tier_badge() }} {# Shows current tier as colored badge #}
+```
+
+## Store Dashboard Integration
+
+The store dashboard (`/store/{code}/dashboard`) now includes:
+
+1. **Tier Badge**: Shows current subscription tier in header
+2. **Usage Bars**: Visual progress bars for orders, products, team members
+3. **Upgrade Prompts**: Contextual upgrade recommendations when approaching limits
+4. **Feature Gates**: Locked sections for premium features
+
+## Admin Features Page
+
+Located at `/admin/features`:
+
+- View all 30 features in categorized table
+- Toggle features on/off globally
+- Filter by category
+- Search by name/code
+- View tier requirements
+
+## Admin Tier Management UI
+
+Located at `/admin/subscription-tiers`:
+
+### Overview
+
+The subscription tiers admin page provides full CRUD functionality for managing subscription tiers and their feature assignments.
+
+### Features
+
+1. **Stats Cards**: Display total tiers, active tiers, public tiers, and estimated MRR
+2. **Tier Table**: Sortable list of all tiers with:
+ - Display order
+ - Code (colored badge by tier)
+ - Name
+ - Monthly/Annual pricing
+ - Feature count
+ - Status (Active/Private/Inactive)
+ - Actions (Edit Features, Edit, Activate/Deactivate)
+
+3. **Create/Edit Modal**: Form with all tier fields:
+ - Code and Name
+ - Monthly and Annual pricing (in cents)
+ - Display order
+ - Stripe IDs (optional)
+ - Description
+ - Active/Public toggles
+
+4. **Feature Assignment Slide-over Panel**:
+ - Opens when clicking the puzzle-piece icon
+ - Shows all features grouped by category
+ - Binary features: checkbox selection with Select all/Deselect all per category
+ - Quantitative features: checkbox + numeric limit input for `limit_value`
+ - Feature count in footer
+ - Save to update tier's feature assignments via `TierFeatureLimitEntry[]`
+
+### Files
+
+| File | Purpose |
+|------|---------|
+| `app/templates/admin/subscription-tiers.html` | Page template |
+| `static/admin/js/subscription-tiers.js` | Alpine.js component |
+| `app/routes/admin_pages.py` | Route registration |
+
+### API Endpoints Used
+
+| Action | Method | Endpoint |
+|--------|--------|----------|
+| Load tiers | GET | `/api/v1/admin/subscriptions/tiers` |
+| Load stats | GET | `/api/v1/admin/subscriptions/stats` |
+| Create tier | POST | `/api/v1/admin/subscriptions/tiers` |
+| Update tier | PATCH | `/api/v1/admin/subscriptions/tiers/{code}` |
+| Delete tier | DELETE | `/api/v1/admin/subscriptions/tiers/{code}` |
+| Load feature catalog | GET | `/api/v1/admin/subscriptions/features/catalog` |
+| Get tier feature limits | GET | `/api/v1/admin/subscriptions/features/tiers/{code}/limits` |
+| Update tier feature limits | PUT | `/api/v1/admin/subscriptions/features/tiers/{code}/limits` |
+
+## Migration
+
+The features are seeded via Alembic migration:
+
+```
+alembic/versions/n2c3d4e5f6a7_add_features_table.py
+```
+
+This creates:
+- `features` table with 30 default features
+- `store_feature_overrides` table for per-store exceptions
+
+## Testing
+
+Unit tests located in:
+- `tests/unit/services/test_feature_service.py`
+- `tests/unit/services/test_usage_service.py`
+
+Run tests:
+```bash
+pytest tests/unit/services/test_feature_service.py -v
+pytest tests/unit/services/test_usage_service.py -v
+```
+
+## Architecture Compliance
+
+All JavaScript files follow architecture rules:
+- JS-003: Alpine components use `store*` naming convention
+- JS-005: Init guards prevent duplicate initialization
+- JS-006: Async operations have try/catch error handling
+- JS-008: API calls use `apiClient` (not raw `fetch()`)
+- JS-009: Notifications use `Utils.showToast()`
+
+## Related Documentation
+
+- [Subscription Billing](subscription-system.md) - Core subscription system
+- [Subscription Workflow Plan](subscription-workflow.md) - Implementation roadmap
diff --git a/app/modules/billing/docs/index.md b/app/modules/billing/docs/index.md
new file mode 100644
index 00000000..73e6e178
--- /dev/null
+++ b/app/modules/billing/docs/index.md
@@ -0,0 +1,74 @@
+# Billing & Subscriptions
+
+Core subscription management, tier limits, store billing, and invoice history. Provides tier-based feature gating used throughout the platform. Uses the payments module for actual payment processing.
+
+## Overview
+
+| Aspect | Detail |
+|--------|--------|
+| Code | `billing` |
+| Classification | Core |
+| Dependencies | `payments` |
+| Status | Active |
+
+## Features
+
+- `subscription_management` — Subscription lifecycle management
+- `billing_history` — Billing and payment history
+- `invoice_generation` — Automatic invoice generation
+- `subscription_analytics` — Subscription metrics and analytics
+- `trial_management` — Free trial period management
+- `limit_overrides` — Per-store tier limit overrides
+
+## Permissions
+
+| Permission | Description |
+|------------|-------------|
+| `billing.view_tiers` | View subscription tiers |
+| `billing.manage_tiers` | Manage subscription tiers |
+| `billing.view_subscriptions` | View subscriptions |
+| `billing.manage_subscriptions` | Manage subscriptions |
+| `billing.view_invoices` | View invoices |
+
+## Data Model
+
+See [Data Model](data-model.md) for full entity relationships.
+
+- **SubscriptionTier** — Tier definitions with Stripe price IDs
+- **TierFeatureLimit** — Per-tier feature limits (feature_code + limit_value)
+- **MerchantSubscription** — Per-merchant+platform subscription state
+- **MerchantFeatureOverride** — Per-merchant feature limit overrides
+- **AddOnProduct / StoreAddOn** — Purchasable add-ons
+- **BillingHistory** — Invoice and payment records
+- **StripeWebhookEvent** — Webhook idempotency tracking
+
+## API Endpoints
+
+| Method | Path | Description |
+|--------|------|-------------|
+| `*` | `/api/v1/admin/billing/*` | Admin billing management |
+| `*` | `/api/v1/admin/features/*` | Feature/tier management |
+| `*` | `/api/v1/merchant/billing/*` | Merchant billing endpoints |
+| `*` | `/api/v1/platform/billing/*` | Platform-wide billing stats |
+
+## Scheduled Tasks
+
+| Task | Schedule | Description |
+|------|----------|-------------|
+| `billing.reset_period_counters` | Daily 00:05 | Reset period-based usage counters |
+| `billing.check_trial_expirations` | Daily 01:00 | Check and handle expired trials |
+| `billing.sync_stripe_status` | Hourly :30 | Sync subscription status with Stripe |
+| `billing.cleanup_stale_subscriptions` | Weekly Sunday 03:00 | Clean up stale subscription records |
+
+## Configuration
+
+Configured via Stripe environment variables and tier definitions in the admin panel.
+
+## Additional Documentation
+
+- [Data Model](data-model.md) — Entity relationships and database schema
+- [Subscription System](subscription-system.md) — Architecture, feature providers, API reference
+- [Feature Gating](feature-gating.md) — Tier-based feature access control and UI integration
+- [Tier Management](tier-management.md) — Admin guide for managing subscription tiers
+- [Subscription Workflow](subscription-workflow.md) — Subscription lifecycle and implementation phases
+- [Stripe Integration](stripe-integration.md) — Stripe Connect setup, webhooks, payment flow
diff --git a/app/modules/billing/docs/stripe-integration.md b/app/modules/billing/docs/stripe-integration.md
new file mode 100644
index 00000000..d06817ee
--- /dev/null
+++ b/app/modules/billing/docs/stripe-integration.md
@@ -0,0 +1,617 @@
+# Stripe Payment Integration - Multi-Tenant Ecommerce Platform
+
+## Architecture Overview
+
+The payment integration uses **Stripe Connect** to handle multi-store payments, enabling:
+- Each store to receive payments directly
+- Platform to collect fees/commissions
+- Proper financial isolation between stores
+- Compliance with financial regulations
+
+## Payment Models
+
+### Database Models
+
+```python
+# models/database/payment.py
+from decimal import Decimal
+from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, Numeric
+from sqlalchemy.orm import relationship
+from app.core.database import Base
+from .base import TimestampMixin
+
+
+class StorePaymentConfig(Base, TimestampMixin):
+ """Store-specific payment configuration."""
+ __tablename__ = "store_payment_configs"
+
+ id = Column(Integer, primary_key=True, index=True)
+ store_id = Column(Integer, ForeignKey("stores.id"), nullable=False, unique=True)
+
+ # Stripe Connect configuration
+ stripe_account_id = Column(String(255)) # Stripe Connect account ID
+ stripe_account_status = Column(String(50)) # pending, active, restricted, inactive
+ stripe_onboarding_url = Column(Text) # Onboarding link for store
+ stripe_dashboard_url = Column(Text) # Store's Stripe dashboard
+
+ # Payment settings
+ accepts_payments = Column(Boolean, default=False)
+ currency = Column(String(3), default="EUR")
+ platform_fee_percentage = Column(Numeric(5, 2), default=2.5) # Platform commission
+
+ # Payout settings
+ payout_schedule = Column(String(20), default="weekly") # daily, weekly, monthly
+ minimum_payout = Column(Numeric(10, 2), default=20.00)
+
+ # Relationships
+ store = relationship("Store", back_populates="payment_config")
+
+ def __repr__(self):
+ return f""
+
+
+class Payment(Base, TimestampMixin):
+ """Payment records for orders."""
+ __tablename__ = "payments"
+
+ id = Column(Integer, primary_key=True, index=True)
+ store_id = Column(Integer, ForeignKey("stores.id"), nullable=False)
+ order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
+ customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False)
+
+ # Stripe payment details
+ stripe_payment_intent_id = Column(String(255), unique=True, index=True)
+ stripe_charge_id = Column(String(255), index=True)
+ stripe_transfer_id = Column(String(255)) # Transfer to store account
+
+ # Payment amounts (in cents to avoid floating point issues)
+ amount_total = Column(Integer, nullable=False) # Total customer payment
+ amount_store = Column(Integer, nullable=False) # Amount to store
+ amount_platform_fee = Column(Integer, nullable=False) # Platform commission
+ currency = Column(String(3), default="EUR")
+
+ # Payment status
+ status = Column(String(50), nullable=False) # pending, succeeded, failed, refunded
+ payment_method = Column(String(50)) # card, bank_transfer, etc.
+
+ # Metadata
+ stripe_metadata = Column(Text) # JSON string of Stripe metadata
+ failure_reason = Column(Text)
+ refund_reason = Column(Text)
+
+ # Timestamps
+ paid_at = Column(DateTime)
+ refunded_at = Column(DateTime)
+
+ # Relationships
+ store = relationship("Store")
+ order = relationship("Order", back_populates="payment")
+ customer = relationship("Customer")
+
+ def __repr__(self):
+ return f""
+
+ @property
+ def amount_total_euros(self):
+ """Convert cents to euros for display."""
+ return self.amount_total / 100
+
+ @property
+ def amount_store_euros(self):
+ """Convert cents to euros for display."""
+ return self.amount_store / 100
+
+
+class PaymentMethod(Base, TimestampMixin):
+ """Saved customer payment methods."""
+ __tablename__ = "payment_methods"
+
+ id = Column(Integer, primary_key=True, index=True)
+ store_id = Column(Integer, ForeignKey("stores.id"), nullable=False)
+ customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False)
+
+ # Stripe payment method details
+ stripe_payment_method_id = Column(String(255), nullable=False, index=True)
+ payment_method_type = Column(String(50), nullable=False) # card, sepa_debit, etc.
+
+ # Card details (if applicable)
+ card_brand = Column(String(50)) # visa, mastercard, etc.
+ card_last4 = Column(String(4))
+ card_exp_month = Column(Integer)
+ card_exp_year = Column(Integer)
+
+ # Settings
+ is_default = Column(Boolean, default=False)
+ is_active = Column(Boolean, default=True)
+
+ # Relationships
+ store = relationship("Store")
+ customer = relationship("Customer")
+
+ def __repr__(self):
+ return f""
+```
+
+### Updated Order Model
+
+```python
+# Update models/database/order.py
+class Order(Base, TimestampMixin):
+ # ... existing fields ...
+
+ # Payment integration
+ payment_status = Column(String(50), default="pending") # pending, paid, failed, refunded
+ payment_intent_id = Column(String(255)) # Stripe PaymentIntent ID
+ total_amount_cents = Column(Integer, nullable=False) # Amount in cents
+
+ # Relationships
+ payment = relationship("Payment", back_populates="order", uselist=False)
+
+ @property
+ def total_amount_euros(self):
+ """Convert cents to euros for display."""
+ return self.total_amount_cents / 100 if self.total_amount_cents else 0
+```
+
+## Payment Service Integration
+
+### Stripe Service
+
+```python
+# services/payment_service.py
+import stripe
+import json
+import logging
+from decimal import Decimal
+from typing import Dict, Optional
+from sqlalchemy.orm import Session
+
+from app.core.config import settings
+from models.database.payment import Payment, StorePaymentConfig
+from models.database.order import Order
+from models.database.store import Store
+from app.exceptions.payment import *
+
+logger = logging.getLogger(__name__)
+
+# Configure Stripe
+stripe.api_key = settings.stripe_secret_key
+
+
+class PaymentService:
+ """Service for handling Stripe payments in multi-tenant environment."""
+
+ def __init__(self, db: Session):
+ self.db = db
+
+ def create_payment_intent(
+ self,
+ store_id: int,
+ order_id: int,
+ amount_euros: Decimal,
+ customer_email: str,
+ metadata: Optional[Dict] = None
+ ) -> Dict:
+ """Create Stripe PaymentIntent for store order."""
+
+ # Get store payment configuration
+ payment_config = self.get_store_payment_config(store_id)
+ if not payment_config.accepts_payments:
+ raise PaymentNotConfiguredException(f"Store {store_id} not configured for payments")
+
+ # Calculate amounts
+ amount_cents = int(amount_euros * 100)
+ platform_fee_cents = int(amount_cents * (payment_config.platform_fee_percentage / 100))
+ store_amount_cents = amount_cents - platform_fee_cents
+
+ try:
+ # Create PaymentIntent with Stripe Connect
+ payment_intent = stripe.PaymentIntent.create(
+ amount=amount_cents,
+ currency=payment_config.currency.lower(),
+ application_fee_amount=platform_fee_cents,
+ transfer_data={
+ 'destination': payment_config.stripe_account_id,
+ },
+ metadata={
+ 'store_id': str(store_id),
+ 'order_id': str(order_id),
+ 'platform': 'multi_tenant_ecommerce',
+ **(metadata or {})
+ },
+ receipt_email=customer_email,
+ description=f"Order payment for store {store_id}"
+ )
+
+ # Create payment record
+ payment = Payment(
+ store_id=store_id,
+ order_id=order_id,
+ customer_id=self.get_order_customer_id(order_id),
+ stripe_payment_intent_id=payment_intent.id,
+ amount_total=amount_cents,
+ amount_store=store_amount_cents,
+ amount_platform_fee=platform_fee_cents,
+ currency=payment_config.currency,
+ status='pending',
+ stripe_metadata=json.dumps(payment_intent.metadata)
+ )
+
+ self.db.add(payment)
+
+ # Update order
+ order = self.db.query(Order).filter(Order.id == order_id).first()
+ if order:
+ order.payment_intent_id = payment_intent.id
+ order.payment_status = 'pending'
+
+ self.db.commit()
+
+ return {
+ 'payment_intent_id': payment_intent.id,
+ 'client_secret': payment_intent.client_secret,
+ 'amount_total': amount_euros,
+ 'amount_store': store_amount_cents / 100,
+ 'platform_fee': platform_fee_cents / 100,
+ 'currency': payment_config.currency
+ }
+
+ except stripe.error.StripeError as e:
+ logger.error(f"Stripe error creating PaymentIntent: {e}")
+ raise PaymentProcessingException(f"Payment processing failed: {str(e)}")
+
+ def confirm_payment(self, payment_intent_id: str) -> Payment:
+ """Confirm payment and update records."""
+
+ try:
+ # Retrieve PaymentIntent from Stripe
+ payment_intent = stripe.PaymentIntent.retrieve(payment_intent_id)
+
+ # Find payment record
+ payment = self.db.query(Payment).filter(
+ Payment.stripe_payment_intent_id == payment_intent_id
+ ).first()
+
+ if not payment:
+ raise PaymentNotFoundException(f"Payment not found for intent {payment_intent_id}")
+
+ # Update payment status based on Stripe status
+ if payment_intent.status == 'succeeded':
+ payment.status = 'succeeded'
+ payment.stripe_charge_id = payment_intent.charges.data[0].id if payment_intent.charges.data else None
+ payment.paid_at = datetime.utcnow()
+
+ # Update order status
+ order = self.db.query(Order).filter(Order.id == payment.order_id).first()
+ if order:
+ order.payment_status = 'paid'
+ order.status = 'processing' # Move order to processing
+
+ elif payment_intent.status == 'payment_failed':
+ payment.status = 'failed'
+ payment.failure_reason = payment_intent.last_payment_error.message if payment_intent.last_payment_error else "Unknown error"
+
+ # Update order status
+ order = self.db.query(Order).filter(Order.id == payment.order_id).first()
+ if order:
+ order.payment_status = 'failed'
+
+ self.db.commit()
+
+ return payment
+
+ except stripe.error.StripeError as e:
+ logger.error(f"Stripe error confirming payment: {e}")
+ raise PaymentProcessingException(f"Payment confirmation failed: {str(e)}")
+
+ def create_store_stripe_account(self, store_id: int, store_data: Dict) -> str:
+ """Create Stripe Connect account for store."""
+
+ try:
+ # Create Stripe Connect Express account
+ account = stripe.Account.create(
+ type='express',
+ country='LU', # Luxembourg
+ email=store_data.get('business_email'),
+ capabilities={
+ 'card_payments': {'requested': True},
+ 'transfers': {'requested': True},
+ },
+ business_type='merchant',
+ merchant={
+ 'name': store_data.get('business_name'),
+ 'phone': store_data.get('business_phone'),
+ 'address': {
+ 'line1': store_data.get('address_line1'),
+ 'city': store_data.get('city'),
+ 'postal_code': store_data.get('postal_code'),
+ 'country': 'LU'
+ }
+ },
+ metadata={
+ 'store_id': str(store_id),
+ 'platform': 'multi_tenant_ecommerce'
+ }
+ )
+
+ # Update or create payment configuration
+ payment_config = self.get_or_create_store_payment_config(store_id)
+ payment_config.stripe_account_id = account.id
+ payment_config.stripe_account_status = account.charges_enabled and account.payouts_enabled and 'active' or 'pending'
+
+ self.db.commit()
+
+ return account.id
+
+ except stripe.error.StripeError as e:
+ logger.error(f"Stripe error creating account: {e}")
+ raise PaymentConfigurationException(f"Failed to create payment account: {str(e)}")
+
+ def create_onboarding_link(self, store_id: int) -> str:
+ """Create Stripe onboarding link for store."""
+
+ payment_config = self.get_store_payment_config(store_id)
+ if not payment_config.stripe_account_id:
+ raise PaymentNotConfiguredException("Store does not have Stripe account")
+
+ try:
+ account_link = stripe.AccountLink.create(
+ account=payment_config.stripe_account_id,
+ refresh_url=f"{settings.frontend_url}/store/admin/payments/refresh",
+ return_url=f"{settings.frontend_url}/store/admin/payments/success",
+ type='account_onboarding',
+ )
+
+ # Update onboarding URL
+ payment_config.stripe_onboarding_url = account_link.url
+ self.db.commit()
+
+ return account_link.url
+
+ except stripe.error.StripeError as e:
+ logger.error(f"Stripe error creating onboarding link: {e}")
+ raise PaymentConfigurationException(f"Failed to create onboarding link: {str(e)}")
+
+ def get_store_payment_config(self, store_id: int) -> StorePaymentConfig:
+ """Get store payment configuration."""
+ config = self.db.query(StorePaymentConfig).filter(
+ StorePaymentConfig.store_id == store_id
+ ).first()
+
+ if not config:
+ raise PaymentNotConfiguredException(f"No payment configuration for store {store_id}")
+
+ return config
+
+ def webhook_handler(self, event_type: str, event_data: Dict) -> None:
+ """Handle Stripe webhook events."""
+
+ if event_type == 'payment_intent.succeeded':
+ payment_intent_id = event_data['object']['id']
+ self.confirm_payment(payment_intent_id)
+
+ elif event_type == 'payment_intent.payment_failed':
+ payment_intent_id = event_data['object']['id']
+ self.confirm_payment(payment_intent_id)
+
+ elif event_type == 'account.updated':
+ # Update store account status
+ account_id = event_data['object']['id']
+ self.update_store_account_status(account_id, event_data['object'])
+
+ # Add more webhook handlers as needed
+```
+
+## API Endpoints
+
+### Payment APIs
+
+```python
+# app/api/v1/store/payments.py
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.orm import Session
+
+from app.core.database import get_db
+from middleware.store_context import require_store_context
+from models.database.store import Store
+from services.payment_service import PaymentService
+
+router = APIRouter(prefix="/payments", tags=["store-payments"])
+
+
+@router.get("/config")
+async def get_payment_config(
+ store: Store = Depends(require_store_context()),
+ db: Session = Depends(get_db)
+):
+ """Get store payment configuration."""
+ payment_service = PaymentService(db)
+
+ try:
+ config = payment_service.get_store_payment_config(store.id)
+ return {
+ "stripe_account_id": config.stripe_account_id,
+ "account_status": config.stripe_account_status,
+ "accepts_payments": config.accepts_payments,
+ "currency": config.currency,
+ "platform_fee_percentage": float(config.platform_fee_percentage),
+ "needs_onboarding": config.stripe_account_status != 'active'
+ }
+ except Exception:
+ return {
+ "stripe_account_id": None,
+ "account_status": "not_configured",
+ "accepts_payments": False,
+ "needs_setup": True
+ }
+
+
+@router.post("/setup")
+async def setup_payments(
+ setup_data: dict,
+ store: Store = Depends(require_store_context()),
+ db: Session = Depends(get_db)
+):
+ """Set up Stripe payments for store."""
+ payment_service = PaymentService(db)
+
+ store_data = {
+ "business_name": store.name,
+ "business_email": store.business_email,
+ "business_phone": store.business_phone,
+ **setup_data
+ }
+
+ account_id = payment_service.create_store_stripe_account(store.id, store_data)
+ onboarding_url = payment_service.create_onboarding_link(store.id)
+
+ return {
+ "stripe_account_id": account_id,
+ "onboarding_url": onboarding_url,
+ "message": "Payment setup initiated. Complete onboarding to accept payments."
+ }
+
+
+# app/api/v1/platform/stores/payments.py
+@router.post("/{store_id}/payments/create-intent")
+async def create_payment_intent(
+ store_id: int,
+ payment_data: dict,
+ db: Session = Depends(get_db)
+):
+ """Create payment intent for customer order."""
+ payment_service = PaymentService(db)
+
+ payment_intent = payment_service.create_payment_intent(
+ store_id=store_id,
+ order_id=payment_data['order_id'],
+ amount_euros=Decimal(str(payment_data['amount'])),
+ customer_email=payment_data['customer_email'],
+ metadata=payment_data.get('metadata', {})
+ )
+
+ return payment_intent
+
+
+@router.post("/webhooks/stripe")
+async def stripe_webhook(
+ request: Request,
+ db: Session = Depends(get_db)
+):
+ """Handle Stripe webhook events."""
+ import stripe
+
+ payload = await request.body()
+ sig_header = request.headers.get('stripe-signature')
+
+ try:
+ event = stripe.Webhook.construct_event(
+ payload, sig_header, settings.stripe_webhook_secret
+ )
+ except ValueError:
+ raise HTTPException(status_code=400, detail="Invalid payload")
+ except stripe.error.SignatureVerificationError:
+ raise HTTPException(status_code=400, detail="Invalid signature")
+
+ payment_service = PaymentService(db)
+ payment_service.webhook_handler(event['type'], event['data'])
+
+ return {"status": "success"}
+```
+
+## Frontend Integration
+
+### Checkout Process
+
+```javascript
+// frontend/js/storefront/checkout.js
+class CheckoutManager {
+ constructor(storeId) {
+ this.storeId = storeId;
+ this.stripe = Stripe(STRIPE_PUBLISHABLE_KEY);
+ this.elements = this.stripe.elements();
+ this.paymentElement = null;
+ }
+
+ async initializePayment(orderData) {
+ // Create payment intent
+ const response = await fetch(`/api/v1/platform/stores/${this.storeId}/payments/create-intent`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ order_id: orderData.orderId,
+ amount: orderData.total,
+ customer_email: orderData.customerEmail
+ })
+ });
+
+ const { client_secret, amount_total, platform_fee } = await response.json();
+
+ // Display payment breakdown
+ this.displayPaymentBreakdown(amount_total, platform_fee);
+
+ // Create payment element
+ this.paymentElement = this.elements.create('payment', {
+ clientSecret: client_secret
+ });
+
+ this.paymentElement.mount('#payment-element');
+ }
+
+ async confirmPayment(orderData) {
+ const { error } = await this.stripe.confirmPayment({
+ elements: this.elements,
+ confirmParams: {
+ return_url: `${window.location.origin}/storefront/order-confirmation`,
+ receipt_email: orderData.customerEmail
+ }
+ });
+
+ if (error) {
+ this.showPaymentError(error.message);
+ }
+ }
+}
+```
+
+## Updated Workflow Integration
+
+### Enhanced Customer Purchase Workflow
+
+```
+Customer adds products to cart
+ ↓
+Customer proceeds to checkout
+ ↓
+System creates Order (payment_status: pending)
+ ↓
+Frontend calls POST /api/v1/platform/stores/{store_id}/payments/create-intent
+ ↓
+PaymentService creates Stripe PaymentIntent with store destination
+ ↓
+Customer completes payment with Stripe Elements
+ ↓
+Stripe webhook confirms payment
+ ↓
+PaymentService updates Order (payment_status: paid, status: processing)
+ ↓
+Store receives order for fulfillment
+```
+
+### Payment Configuration Workflow
+
+```
+Store accesses payment settings
+ ↓
+POST /api/v1/store/payments/setup
+ ↓
+System creates Stripe Connect account
+ ↓
+Store completes Stripe onboarding
+ ↓
+Webhook updates account status to 'active'
+ ↓
+Store can now accept payments
+```
+
+This integration provides secure, compliant payment processing while maintaining store isolation and enabling proper revenue distribution between stores and the platform.
diff --git a/app/modules/billing/docs/subscription-system.md b/app/modules/billing/docs/subscription-system.md
new file mode 100644
index 00000000..31d2ce9c
--- /dev/null
+++ b/app/modules/billing/docs/subscription-system.md
@@ -0,0 +1,182 @@
+# Subscription & Billing System
+
+The platform provides a comprehensive subscription and billing system for managing merchant subscriptions, feature-based usage limits, and payments through Stripe.
+
+## Overview
+
+The billing system enables:
+
+- **Subscription Tiers**: Database-driven tier definitions with configurable feature limits
+- **Feature Provider Pattern**: Modules declare features and usage via `FeatureProviderProtocol`, aggregated by `FeatureAggregatorService`
+- **Dynamic Usage Tracking**: Quantitative features (orders, products, team members) tracked per merchant with dynamic limits from `TierFeatureLimit`
+- **Binary Feature Gating**: Toggle-based features (analytics, API access, white-label) controlled per tier
+- **Merchant-Level Billing**: Subscriptions are per merchant+platform, not per store
+- **Stripe Integration**: Checkout sessions, customer portal, and webhook handling
+- **Add-ons**: Optional purchasable items (domains, SSL, email packages)
+- **Capacity Forecasting**: Growth trends and scaling recommendations
+- **Background Jobs**: Automated subscription lifecycle management
+
+## Architecture
+
+### Key Concepts
+
+The billing system uses a **feature provider pattern** where:
+
+1. **`TierFeatureLimit`** replaces hardcoded tier columns (`orders_per_month`, `products_limit`, `team_members`). Each feature limit is a row linking a tier to a feature code with a `limit_value`.
+2. **`MerchantFeatureOverride`** provides per-merchant exceptions to tier defaults.
+3. **Module feature providers** implement `FeatureProviderProtocol` to supply current usage data.
+4. **`FeatureAggregatorService`** collects usage from all providers and combines it with tier limits to produce `FeatureSummary` records.
+
+```
+┌──────────────────────────────────────────────────────────────┐
+│ Frontend Page Request │
+│ (Store Billing, Admin Subscriptions, Admin Store Detail) │
+└──────────────────────────────────────────────────────────────┘
+ │
+ ▼
+┌──────────────────────────────────────────────────────────────┐
+│ FeatureAggregatorService │
+│ (app/modules/billing/services/feature_service.py) │
+│ │
+│ • Collects feature providers from all enabled modules │
+│ • Queries TierFeatureLimit for limit values │
+│ • Queries MerchantFeatureOverride for per-merchant limits │
+│ • Calls provider.get_current_usage() for live counts │
+│ • Returns FeatureSummary[] with current/limit/percentage │
+└──────────────────────────────────────────────────────────────┘
+ │ │ │
+ ▼ ▼ ▼
+ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐
+ │ catalog module │ │ orders module │ │ tenancy module │
+ │ products count │ │ orders count │ │ team members │
+ └────────────────┘ └────────────────┘ └────────────────┘
+```
+
+### Feature Types
+
+| Type | Description | Example |
+|------|-------------|---------|
+| **Quantitative** | Has a numeric limit with usage tracking | `max_products` (limit: 200, current: 150) |
+| **Binary** | Toggle-based, either enabled or disabled | `analytics_dashboard` (enabled/disabled) |
+
+### FeatureSummary Dataclass
+
+```python
+@dataclass
+class FeatureSummary:
+ code: str # e.g., "max_products"
+ name_key: str # i18n key for display name
+ limit: int | None # None = unlimited
+ current: int # Current usage count
+ remaining: int # Remaining before limit
+ percent_used: float # 0.0 to 100.0
+ feature_type: str # "quantitative" or "binary"
+ scope: str # "tier" or "merchant_override"
+```
+
+### Services
+
+| Service | Purpose |
+|---------|---------|
+| `FeatureAggregatorService` | Aggregates usage from module providers, resolves tier limits + overrides |
+| `BillingService` | Subscription operations, checkout, portal |
+| `SubscriptionService` | Subscription CRUD, tier lookups |
+| `AdminSubscriptionService` | Admin subscription management |
+| `StripeService` | Core Stripe API operations |
+| `CapacityForecastService` | Growth trends, projections |
+
+### Background Tasks
+
+| Task | Schedule | Purpose |
+|------|----------|---------|
+| `reset_period_counters` | Daily | Reset order counters at period end |
+| `check_trial_expirations` | Daily | Expire trials without payment method |
+| `sync_stripe_status` | Hourly | Sync status with Stripe |
+| `cleanup_stale_subscriptions` | Weekly | Clean up old cancelled subscriptions |
+| `capture_capacity_snapshot` | Daily | Capture capacity metrics snapshot |
+
+## API Endpoints
+
+### Store Billing API (`/api/v1/store/billing`)
+
+| Endpoint | Method | Purpose |
+|----------|--------|---------|
+| `/subscription` | GET | Current subscription status |
+| `/tiers` | GET | Available tiers for upgrade |
+| `/usage` | GET | Dynamic usage metrics (from feature providers) |
+| `/checkout` | POST | Create Stripe checkout session |
+| `/portal` | POST | Create Stripe customer portal session |
+| `/invoices` | GET | Invoice history |
+| `/change-tier` | POST | Upgrade/downgrade tier |
+| `/addons` | GET | Available add-on products |
+| `/my-addons` | GET | Store's purchased add-ons |
+| `/addons/purchase` | POST | Purchase an add-on |
+| `/cancel` | POST | Cancel subscription |
+| `/reactivate` | POST | Reactivate cancelled subscription |
+
+### Admin Subscription API (`/api/v1/admin/subscriptions`)
+
+| Endpoint | Method | Purpose |
+|----------|--------|---------|
+| `/tiers` | GET/POST | List/create tiers |
+| `/tiers/{code}` | PATCH/DELETE | Update/delete tier |
+| `/stats` | GET | Subscription statistics |
+| `/merchants/{id}/platforms/{pid}` | GET/PUT | Get/update merchant subscription |
+| `/store/{store_id}` | GET | Convenience: subscription + usage for a store |
+
+### Admin Feature Management API (`/api/v1/admin/subscriptions/features`)
+
+| Endpoint | Method | Purpose |
+|----------|--------|---------|
+| `/catalog` | GET | Feature catalog grouped by category |
+| `/tiers/{code}/limits` | GET/PUT | Get/upsert feature limits for a tier |
+| `/merchants/{id}/overrides` | GET/PUT | Get/upsert merchant feature overrides |
+
+## Subscription Tiers
+
+Tiers are stored in `subscription_tiers` with feature limits in `tier_feature_limits`:
+
+```
+SubscriptionTier (essential)
+ ├── TierFeatureLimit: max_products = 200
+ ├── TierFeatureLimit: max_orders_per_month = 100
+ ├── TierFeatureLimit: max_team_members = 1
+ └── TierFeatureLimit: basic_analytics (binary, enabled)
+
+SubscriptionTier (professional)
+ ├── TierFeatureLimit: max_products = NULL (unlimited)
+ ├── TierFeatureLimit: max_orders_per_month = 500
+ ├── TierFeatureLimit: max_team_members = 3
+ └── TierFeatureLimit: analytics_dashboard (binary, enabled)
+```
+
+## Add-ons
+
+| Code | Name | Category | Price |
+|------|------|----------|-------|
+| `domain` | Custom Domain | domain | €15/year |
+| `ssl_premium` | Premium SSL | ssl | €49/year |
+| `email_5` | 5 Email Addresses | email | €5/month |
+| `email_10` | 10 Email Addresses | email | €9/month |
+| `email_25` | 25 Email Addresses | email | €19/month |
+
+## Exception Handling
+
+| Exception | HTTP | Description |
+|-----------|------|-------------|
+| `PaymentSystemNotConfiguredException` | 503 | Stripe not configured |
+| `TierNotFoundException` | 404 | Invalid tier code |
+| `StripePriceNotConfiguredException` | 400 | No Stripe price for tier |
+| `NoActiveSubscriptionException` | 400 | Operation requires subscription |
+| `SubscriptionNotCancelledException` | 400 | Cannot reactivate active subscription |
+
+## Related Documentation
+
+- [Data Model](data-model.md) — Entity relationships
+- [Feature Gating](feature-gating.md) — Feature access control and UI integration
+- [Stripe Integration](stripe-integration.md) — Payment setup
+- [Tier Management](tier-management.md) — Admin guide for tier management
+- [Subscription Workflow](subscription-workflow.md) — Subscription lifecycle
+- [Metrics Provider Pattern](../../architecture/metrics-provider-pattern.md) — Protocol-based metrics
+- [Capacity Monitoring](../../operations/capacity-monitoring.md) — Monitoring guide
+- [Capacity Planning](../../architecture/capacity-planning.md) — Infrastructure sizing
diff --git a/app/modules/billing/docs/subscription-workflow.md b/app/modules/billing/docs/subscription-workflow.md
new file mode 100644
index 00000000..94a66bc6
--- /dev/null
+++ b/app/modules/billing/docs/subscription-workflow.md
@@ -0,0 +1,454 @@
+# Subscription Workflow Plan
+
+## Overview
+
+End-to-end subscription management workflow for stores on the platform.
+
+---
+
+## 1. Store Subscribes to a Tier
+
+### 1.1 New Store Registration Flow
+
+```
+Store Registration → Select Tier → Trial Period → Payment Setup → Active Subscription
+```
+
+**Steps:**
+1. Store creates account (existing flow)
+2. During onboarding, store selects a tier:
+ - Show tier comparison cards (Essential, Professional, Business, Enterprise)
+ - Highlight features and limits for each tier
+ - Default to 14-day trial on selected tier
+3. Create `StoreSubscription` record with:
+ - `tier` = selected tier code
+ - `status` = "trial"
+ - `trial_ends_at` = now + 14 days
+ - `period_start` / `period_end` set for trial period
+4. Before trial ends, prompt store to add payment method
+5. On payment method added → Create Stripe subscription → Status becomes "active"
+
+### 1.2 Database Changes Required
+
+**Add FK relationship to `subscription_tiers`:**
+```python
+# StoreSubscription - Add proper FK
+tier_id = Column(Integer, ForeignKey("subscription_tiers.id"), nullable=True)
+tier_code = Column(String(20), nullable=False) # Keep for backwards compat
+
+# Relationship
+tier_obj = relationship("SubscriptionTier", backref="subscriptions")
+```
+
+**Migration:**
+1. Add `tier_id` column (nullable initially)
+2. Populate `tier_id` from existing `tier` code values
+3. Add FK constraint
+
+### 1.3 API Endpoints
+
+| Endpoint | Method | Description |
+|----------|--------|-------------|
+| `/api/v1/store/subscription/tiers` | GET | List available tiers for selection |
+| `/api/v1/store/subscription/select-tier` | POST | Select tier during onboarding |
+| `/api/v1/store/subscription/setup-payment` | POST | Create Stripe checkout for payment |
+
+---
+
+## 2. Admin Views Subscription on Store Page
+
+### 2.1 Store Detail Page Enhancement
+
+**Location:** `/admin/stores/{store_id}`
+
+**New Subscription Card:**
+```
+┌─────────────────────────────────────────────────────────────┐
+│ Subscription [Edit] │
+├─────────────────────────────────────────────────────────────┤
+│ Tier: Professional Status: Active │
+│ Price: €99/month Since: Jan 15, 2025 │
+│ Next Billing: Feb 15, 2025 │
+├─────────────────────────────────────────────────────────────┤
+│ Usage This Period │
+│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
+│ │ Orders │ │ Products │ │ Team Members │ │
+│ │ 234 / 500 │ │ 156 / ∞ │ │ 2 / 3 │ │
+│ │ ████████░░ │ │ ████████████ │ │ ██████░░░░ │ │
+│ └──────────────┘ └──────────────┘ └──────────────┘ │
+├─────────────────────────────────────────────────────────────┤
+│ Add-ons: Custom Domain (mydomain.com), 5 Email Addresses │
+└─────────────────────────────────────────────────────────────┘
+```
+
+### 2.2 Files to Modify
+
+- `app/templates/admin/store-detail.html` - Add subscription card
+- `static/admin/js/store-detail.js` - Load subscription data
+- `app/api/v1/admin/stores.py` - Include subscription in store response
+
+### 2.3 Admin Quick Actions
+
+From the store page, admin can:
+- **Change Tier** - Upgrade/downgrade store
+- **Override Limits** - Set custom limits (enterprise deals)
+- **Extend Trial** - Give more trial days
+- **Cancel Subscription** - With reason
+- **Manage Add-ons** - Add/remove add-ons
+
+---
+
+## 3. Tier Upgrade/Downgrade
+
+### 3.1 Admin-Initiated Change
+
+**Location:** Admin store page → Subscription card → [Edit] button
+
+**Modal: Change Subscription Tier**
+```
+┌─────────────────────────────────────────────────────────┐
+│ Change Subscription Tier [X] │
+├─────────────────────────────────────────────────────────┤
+│ Current: Professional (€99/month) │
+│ │
+│ New Tier: │
+│ ○ Essential (€49/month) - Downgrade │
+│ ● Business (€199/month) - Upgrade │
+│ ○ Enterprise (Custom) - Contact required │
+│ │
+│ When to apply: │
+│ ○ Immediately (prorate current period) │
+│ ● At next billing cycle (Feb 15, 2025) │
+│ │
+│ [ ] Notify store by email │
+│ │
+│ [Cancel] [Apply Change] │
+└─────────────────────────────────────────────────────────┘
+```
+
+### 3.2 Store-Initiated Change
+
+**Location:** Store dashboard → Billing page → [Change Plan]
+
+**Flow:**
+1. Store clicks "Change Plan" on billing page
+2. Shows tier comparison with current tier highlighted
+3. Store selects new tier
+4. For upgrades:
+ - Show prorated amount for immediate change
+ - Or option to change at next billing
+ - Redirect to Stripe checkout if needed
+5. For downgrades:
+ - Always schedule for next billing cycle
+ - Show what features they'll lose
+ - Confirmation required
+
+### 3.3 API Endpoints
+
+| Endpoint | Method | Actor | Description |
+|----------|--------|-------|-------------|
+| `/api/v1/admin/subscriptions/{store_id}/change-tier` | POST | Admin | Change store's tier |
+| `/api/v1/store/billing/change-tier` | POST | Store | Request tier change |
+| `/api/v1/store/billing/preview-change` | POST | Store | Preview proration |
+
+### 3.4 Stripe Integration
+
+**Upgrade (Immediate):**
+```python
+stripe.Subscription.modify(
+ subscription_id,
+ items=[{"price": new_price_id}],
+ proration_behavior="create_prorations"
+)
+```
+
+**Downgrade (Scheduled):**
+```python
+stripe.Subscription.modify(
+ subscription_id,
+ items=[{"price": new_price_id}],
+ proration_behavior="none",
+ billing_cycle_anchor="unchanged"
+)
+# Store scheduled change in our DB
+```
+
+---
+
+## 4. Add-ons Upselling
+
+### 4.1 Where Add-ons Are Displayed
+
+#### A. Store Billing Page
+```
+/store/{code}/billing
+
+┌─────────────────────────────────────────────────────────────┐
+│ Available Add-ons │
+├─────────────────────────────────────────────────────────────┤
+│ ┌─────────────────────┐ ┌─────────────────────┐ │
+│ │ 🌐 Custom Domain │ │ 📧 Email Package │ │
+│ │ €15/year │ │ From €5/month │ │
+│ │ Use your own domain │ │ 5, 10, or 25 emails │ │
+│ │ [Add to Plan] │ │ [Add to Plan] │ │
+│ └─────────────────────┘ └─────────────────────┘ │
+│ │
+│ ┌─────────────────────┐ ┌─────────────────────┐ │
+│ │ 🔒 Premium SSL │ │ 💾 Extra Storage │ │
+│ │ €49/year │ │ €5/month per 10GB │ │
+│ │ EV certificate │ │ More product images │ │
+│ │ [Add to Plan] │ │ [Add to Plan] │ │
+│ └─────────────────────┘ └─────────────────────┘ │
+└─────────────────────────────────────────────────────────────┘
+```
+
+#### B. Contextual Upsells
+
+**When store hits a limit:**
+```
+┌─────────────────────────────────────────────────────────┐
+│ ⚠️ You've reached your order limit for this month │
+│ │
+│ Upgrade to Professional to get 500 orders/month │
+│ [Upgrade Now] [Dismiss] │
+└─────────────────────────────────────────────────────────┘
+```
+
+**In settings when configuring domain:**
+```
+┌─────────────────────────────────────────────────────────┐
+│ 🌐 Custom Domain │
+│ │
+│ Your shop is available at: myshop.platform.com │
+│ │
+│ Want to use your own domain like www.myshop.com? │
+│ Add the Custom Domain add-on for just €15/year │
+│ │
+│ [Add Custom Domain] │
+└─────────────────────────────────────────────────────────┘
+```
+
+#### C. Upgrade Prompts in Tier Comparison
+
+When showing tier comparison, highlight what add-ons come included:
+- Professional: Includes 1 custom domain
+- Business: Includes custom domain + 5 email addresses
+- Enterprise: All add-ons included
+
+### 4.2 Add-on Purchase Flow
+
+```
+Store clicks [Add to Plan]
+ ↓
+Modal: Configure Add-on
+ - Domain: Enter domain name, check availability
+ - Email: Select package (5/10/25)
+ ↓
+Create Stripe checkout session for add-on price
+ ↓
+On success: Create StoreAddOn record
+ ↓
+Provision add-on (domain registration, email setup)
+```
+
+### 4.3 Add-on Management
+
+**Store can view/manage in Billing page:**
+```
+┌─────────────────────────────────────────────────────────────┐
+│ Your Add-ons │
+├─────────────────────────────────────────────────────────────┤
+│ Custom Domain myshop.com €15/year [Manage] │
+│ Email Package 5 addresses €5/month [Manage] │
+│ │
+│ Next billing: Feb 15, 2025 │
+└─────────────────────────────────────────────────────────────┘
+```
+
+### 4.4 Database: `store_addons` Table
+
+```python
+class StoreAddOn(Base):
+ id = Column(Integer, primary_key=True)
+ store_id = Column(Integer, ForeignKey("stores.id"))
+ addon_product_id = Column(Integer, ForeignKey("addon_products.id"))
+
+ # Config (e.g., domain name, email count)
+ config = Column(JSON, nullable=True)
+
+ # Stripe
+ stripe_subscription_item_id = Column(String(100))
+
+ # Status
+ status = Column(String(20)) # active, cancelled, pending_setup
+ provisioned_at = Column(DateTime)
+
+ # Billing
+ quantity = Column(Integer, default=1)
+
+ created_at = Column(DateTime)
+ cancelled_at = Column(DateTime, nullable=True)
+```
+
+---
+
+## 5. Implementation Phases
+
+**Last Updated:** December 31, 2025
+
+### Phase 1: Database & Core (COMPLETED)
+- [x] Add `tier_id` FK to StoreSubscription
+- [x] Create migration with data backfill
+- [x] Update subscription service to use tier relationship
+- [x] Update admin subscription endpoints
+- [x] **NEW:** Add Feature model with 30 features across 8 categories
+- [x] **NEW:** Create FeatureService with caching for tier-based feature checking
+- [x] **NEW:** Add UsageService for limit tracking and upgrade recommendations
+
+### Phase 2: Admin Store Page (PARTIALLY COMPLETE)
+- [x] Add subscription card to store detail page
+- [x] Show usage meters (orders, products, team)
+- [ ] Add "Edit Subscription" modal
+- [ ] Implement tier change API (admin)
+- [x] **NEW:** Add Admin Features page (`/admin/features`)
+- [x] **NEW:** Admin features API (list, update, toggle)
+
+### Phase 3: Store Billing Page (COMPLETED)
+- [x] Create `/store/{code}/billing` page
+- [x] Show current plan and usage
+- [x] Add tier comparison/change UI
+- [x] Implement tier change API (store)
+- [x] Add Stripe checkout integration for upgrades
+- [x] **NEW:** Add feature gate macros for templates
+- [x] **NEW:** Add Alpine.js feature store
+- [x] **NEW:** Add Alpine.js upgrade prompts store
+- [x] **FIX:** Resolved 89 JS architecture violations (JS-005 through JS-009)
+
+### Phase 4: Add-ons (COMPLETED)
+- [x] Seed add-on products in database
+- [x] Add "Available Add-ons" section to billing page
+- [x] Implement add-on purchase flow
+- [x] Create StoreAddOn management (via billing page)
+- [x] Add contextual upsell prompts
+- [x] **FIX:** Fix Stripe webhook to create StoreAddOn records
+
+### Phase 5: Polish & Testing (IN PROGRESS)
+- [ ] Email notifications for tier changes
+- [x] Webhook handling for Stripe events
+- [x] Usage limit enforcement updates
+- [ ] End-to-end testing (manual testing required)
+- [x] Documentation (feature-gating-system.md created)
+
+### Phase 6: Remaining Work (NEW)
+- [ ] Admin tier change modal (upgrade/downgrade stores)
+- [ ] Admin subscription override UI (custom limits for enterprise)
+- [ ] Trial extension from admin panel
+- [ ] Email notifications for tier changes
+- [ ] Email notifications for approaching limits
+- [ ] Grace period handling for failed payments
+- [ ] Integration tests for full billing workflow
+- [ ] Stripe test mode checkout verification
+
+---
+
+## 6. Files Created/Modified
+
+**Last Updated:** December 31, 2025
+
+### New Files (Created)
+| File | Purpose | Status |
+|------|---------|--------|
+| `app/templates/store/billing.html` | Store billing page | DONE |
+| `static/store/js/billing.js` | Billing page JS | DONE |
+| `app/api/v1/store/billing.py` | Store billing endpoints | DONE |
+| `models/database/feature.py` | Feature & StoreFeatureOverride models | DONE |
+| `app/services/feature_service.py` | Feature access control service | DONE |
+| `app/services/usage_service.py` | Usage tracking & limits service | DONE |
+| `app/core/feature_gate.py` | @require_feature decorator & dependency | DONE |
+| `app/api/v1/store/features.py` | Store features API | DONE |
+| `app/api/v1/store/usage.py` | Store usage API | DONE |
+| `app/api/v1/admin/features.py` | Admin features API | DONE |
+| `app/templates/admin/features.html` | Admin features management page | DONE |
+| `app/templates/shared/macros/feature_gate.html` | Jinja2 feature gate macros | DONE |
+| `static/shared/js/feature-store.js` | Alpine.js feature store | DONE |
+| `static/shared/js/upgrade-prompts.js` | Alpine.js upgrade prompts | DONE |
+| `alembic/versions/n2c3d4e5f6a7_add_features_table.py` | Features migration | DONE |
+| `docs/implementation/feature-gating-system.md` | Feature gating documentation | DONE |
+
+### Modified Files
+| File | Changes | Status |
+|------|---------|--------|
+| `models/database/subscription.py` | Add tier_id FK | DONE |
+| `models/database/__init__.py` | Export Feature models | DONE |
+| `app/templates/admin/store-detail.html` | Add subscription card | DONE |
+| `static/admin/js/store-detail.js` | Load subscription data | DONE |
+| `app/api/v1/admin/stores.py` | Include subscription in response | DONE |
+| `app/api/v1/admin/__init__.py` | Register features router | DONE |
+| `app/api/v1/store/__init__.py` | Register features/usage routers | DONE |
+| `app/services/subscription_service.py` | Tier change logic | DONE |
+| `app/templates/store/partials/sidebar.html` | Add Billing link | DONE |
+| `app/templates/store/base.html` | Load feature/upgrade stores | DONE |
+| `app/templates/store/dashboard.html` | Add tier badge & usage bars | DONE |
+| `app/handlers/stripe_webhook.py` | Create StoreAddOn on purchase | DONE |
+| `app/routes/admin_pages.py` | Add features page route | DONE |
+| `static/shared/js/api-client.js` | Add postFormData() & getBlob() | DONE |
+
+### Architecture Fixes (48 files)
+| Rule | Files Fixed | Description |
+|------|-------------|-------------|
+| JS-003 | billing.js | Rename billingData→storeBilling |
+| JS-005 | 15 files | Add init guards |
+| JS-006 | 39 files | Add try/catch to async init |
+| JS-008 | 5 files | Use apiClient not fetch |
+| JS-009 | 30 files | Use Utils.showToast |
+| TPL-009 | validate_architecture.py | Check store templates too |
+
+---
+
+## 7. API Summary
+
+### Admin APIs
+```
+GET /admin/stores/{id} # Includes subscription
+POST /admin/subscriptions/{store_id}/change-tier
+POST /admin/subscriptions/{store_id}/override-limits
+POST /admin/subscriptions/{store_id}/extend-trial
+POST /admin/subscriptions/{store_id}/cancel
+```
+
+### Store APIs
+```
+GET /store/billing/subscription # Current subscription
+GET /store/billing/tiers # Available tiers
+POST /store/billing/preview-change # Preview tier change
+POST /store/billing/change-tier # Request tier change
+POST /store/billing/checkout # Stripe checkout session
+
+GET /store/billing/addons # Available add-ons
+GET /store/billing/my-addons # Store's add-ons
+POST /store/billing/addons/purchase # Purchase add-on
+DELETE /store/billing/addons/{id} # Cancel add-on
+```
+
+---
+
+## 8. Questions to Resolve
+
+1. **Trial without payment method?**
+ - Allow full trial without card, or require card upfront?
+
+2. **Downgrade handling:**
+ - What happens if store has more products than new tier allows?
+ - Block downgrade, or just prevent new products?
+
+3. **Enterprise tier:**
+ - Self-service or contact sales only?
+ - Custom pricing in UI or hidden?
+
+4. **Add-on provisioning:**
+ - Domain: Use reseller API or manual process?
+ - Email: Integrate with email provider or manual?
+
+5. **Grace period:**
+ - How long after payment failure before suspension?
+ - What gets disabled first?
diff --git a/app/modules/billing/docs/tier-management.md b/app/modules/billing/docs/tier-management.md
new file mode 100644
index 00000000..dd399a8f
--- /dev/null
+++ b/app/modules/billing/docs/tier-management.md
@@ -0,0 +1,135 @@
+# Subscription Tier Management
+
+This guide explains how to manage subscription tiers and assign features to them in the admin panel.
+
+## Accessing Tier Management
+
+Navigate to **Admin → Billing & Subscriptions → Subscription Tiers** or go directly to `/admin/subscription-tiers`.
+
+## Dashboard Overview
+
+The tier management page displays:
+
+### Stats Cards
+- **Total Tiers**: Number of configured subscription tiers
+- **Active Tiers**: Tiers currently available for subscription
+- **Public Tiers**: Tiers visible to stores (excludes enterprise/custom)
+- **Est. MRR**: Estimated Monthly Recurring Revenue
+
+### Tier Table
+
+Each tier shows:
+
+| Column | Description |
+|--------|-------------|
+| # | Display order (affects pricing page order) |
+| Code | Unique identifier (e.g., `essential`, `professional`) |
+| Name | Display name shown to stores |
+| Monthly | Monthly price in EUR |
+| Annual | Annual price in EUR (or `-` if not set) |
+| Orders/Mo | Monthly order limit (or `Unlimited`) |
+| Products | Product limit (or `Unlimited`) |
+| Team | Team member limit (or `Unlimited`) |
+| Features | Number of features assigned |
+| Status | Active, Private, or Inactive |
+| Actions | Edit Features, Edit, Activate/Deactivate |
+
+## Managing Tiers
+
+### Creating a New Tier
+
+1. Click **Create Tier** button
+2. Fill in the tier details:
+ - **Code**: Unique lowercase identifier (cannot be changed after creation)
+ - **Name**: Display name for the tier
+ - **Monthly Price**: Price in cents (e.g., 4900 for €49.00)
+ - **Annual Price**: Optional annual price in cents
+ - **Order Limit**: Leave empty for unlimited
+ - **Product Limit**: Leave empty for unlimited
+ - **Team Members**: Leave empty for unlimited
+ - **Display Order**: Controls sort order on pricing pages
+ - **Active**: Whether tier is available
+ - **Public**: Whether tier is visible to stores
+3. Click **Create**
+
+### Editing a Tier
+
+1. Click the **pencil icon** on the tier row
+2. Modify the tier properties
+3. Click **Update**
+
+Note: The tier code cannot be changed after creation.
+
+### Activating/Deactivating Tiers
+
+- Click the **check-circle icon** to activate an inactive tier
+- Click the **x-circle icon** to deactivate an active tier
+
+Deactivating a tier:
+- Does not affect existing subscriptions
+- Hides the tier from new subscription selection
+- Can be reactivated at any time
+
+## Managing Features
+
+### Assigning Features to a Tier
+
+1. Click the **puzzle-piece icon** on the tier row
+2. A slide-over panel opens showing all available features
+3. Features are grouped by category:
+ - Analytics
+ - Product Management
+ - Order Management
+ - Marketing
+ - Support
+ - Integration
+ - Branding
+ - Team
+
+4. Check/uncheck features to include in the tier
+5. Use **Select all** or **Deselect all** per category for bulk actions
+6. The footer shows the total number of selected features
+7. Click **Save Features** to apply changes
+
+### Feature Categories
+
+| Category | Example Features |
+|----------|------------------|
+| Analytics | Basic Analytics, Analytics Dashboard, Custom Reports |
+| Product Management | Bulk Edit, Variants, Bundles, Inventory Alerts |
+| Order Management | Order Automation, Advanced Fulfillment, Multi-Warehouse |
+| Marketing | Discount Codes, Abandoned Cart, Email Marketing, Loyalty |
+| Support | Email Support, Priority Support, Phone Support, Dedicated Manager |
+| Integration | Basic API, Advanced API, Webhooks, Custom Integrations |
+| Branding | Theme Customization, Custom Domain, White Label |
+| Team | Team Management, Role Permissions, Audit Logs |
+
+## Best Practices
+
+### Tier Pricing Strategy
+
+1. **Essential**: Entry-level with basic features and limits
+2. **Professional**: Mid-tier with increased limits and key integrations
+3. **Business**: Full-featured for growing businesses
+4. **Enterprise**: Custom pricing with unlimited everything
+
+### Feature Assignment Tips
+
+- Start with fewer features in lower tiers
+- Ensure each upgrade tier adds meaningful value
+- Keep support features as upgrade incentives
+- API access typically belongs in Business+ tiers
+
+### Stripe Integration
+
+For each tier, you can optionally configure:
+- **Stripe Product ID**: Link to Stripe product
+- **Stripe Monthly Price ID**: Link to monthly price
+- **Stripe Annual Price ID**: Link to annual price
+
+These are required for automated billing via Stripe Checkout.
+
+## Related Documentation
+
+- [Subscription & Billing System](subscription-system.md) - Complete billing documentation
+- [Feature Gating System](feature-gating.md) - Technical feature gating details
diff --git a/app/modules/cart/docs/index.md b/app/modules/cart/docs/index.md
new file mode 100644
index 00000000..4e6d3ff8
--- /dev/null
+++ b/app/modules/cart/docs/index.md
@@ -0,0 +1,41 @@
+# Shopping Cart
+
+Session-based shopping cart for storefronts.
+
+## Overview
+
+| Aspect | Detail |
+|--------|--------|
+| Code | `cart` |
+| Classification | Optional |
+| Dependencies | `inventory`, `catalog` |
+| Status | Active |
+
+## Features
+
+- `cart_management` — Cart creation and management
+- `cart_persistence` — Session-based cart persistence
+- `cart_item_operations` — Add/update/remove cart items
+- `shipping_calculation` — Shipping cost calculation
+- `promotion_application` — Apply promotions and discounts
+
+## Permissions
+
+| Permission | Description |
+|------------|-------------|
+| `cart.view` | View cart data |
+| `cart.manage` | Manage cart settings |
+
+## Data Model
+
+- **Cart** — Shopping cart with session tracking and store scoping
+
+## API Endpoints
+
+| Method | Path | Description |
+|--------|------|-------------|
+| `*` | `/api/v1/storefront/cart/*` | Storefront cart operations |
+
+## Configuration
+
+No module-specific configuration.
diff --git a/app/modules/catalog/docs/architecture.md b/app/modules/catalog/docs/architecture.md
new file mode 100644
index 00000000..75f5ed04
--- /dev/null
+++ b/app/modules/catalog/docs/architecture.md
@@ -0,0 +1,291 @@
+# Product Architecture
+
+## Overview
+
+The product management system uses an **independent copy pattern** where store products (`Product`) are fully independent entities that can optionally reference a marketplace source (`MarketplaceProduct`) for display purposes only.
+
+## Core Principles
+
+| Principle | Description |
+|-----------|-------------|
+| **Full Independence** | Store products have all their own fields - no inheritance or fallback to marketplace |
+| **Optional Source Reference** | `marketplace_product_id` is nullable - products can be created directly |
+| **No Reset Functionality** | No "reset to source" - products are independent from the moment of creation |
+| **Source for Display Only** | Source comparison info is read-only, used for "view original" display |
+
+---
+
+## Architecture Diagram
+
+```
+┌─────────────────────────────────────────────────────────────────────┐
+│ MarketplaceProduct │
+│ (Central Repository - raw imported data from marketplaces) │
+│ │
+│ - marketplace_product_id (unique) │
+│ - gtin, mpn, sku │
+│ - brand, price_cents, sale_price_cents │
+│ - is_digital, product_type_enum │
+│ - translations (via MarketplaceProductTranslation) │
+└──────────────────────────────────────────────────────────────────────┘
+
+ ╳ No runtime dependency
+ │
+ │ Optional FK (for "view source" display only)
+ │ marketplace_product_id (nullable)
+ ▼
+┌─────────────────────────────────────────────────────────────────────┐
+│ Product │
+│ (Store's Independent Product - fully standalone) │
+│ │
+│ === IDENTIFIERS === │
+│ - store_id (required) │
+│ - store_sku │
+│ - gtin, gtin_type │
+│ │
+│ === PRODUCT TYPE (own columns) === │
+│ - is_digital (Boolean) │
+│ - product_type (String: physical, digital, service, subscription) │
+│ │
+│ === PRICING === │
+│ - price_cents, sale_price_cents │
+│ - currency, tax_rate_percent │
+│ │
+│ === CONTENT === │
+│ - brand, condition, availability │
+│ - primary_image_url, additional_images │
+│ - translations (via ProductTranslation) │
+│ │
+│ === STATUS === │
+│ - is_active, is_featured │
+│ │
+│ === SUPPLIER === │
+│ - supplier, cost_cents │
+└─────────────────────────────────────────────────────────────────────┘
+```
+
+---
+
+## Product Creation Patterns
+
+### 1. From Marketplace Source (Import)
+
+When copying from a marketplace product:
+- All fields are **copied** at creation time
+- `marketplace_product_id` is set for source reference
+- No ongoing relationship - product is immediately independent
+
+```python
+# Service copies all fields at import time
+product = Product(
+ store_id=store.id,
+ marketplace_product_id=marketplace_product.id, # Source reference
+ # All fields copied - no inheritance
+ brand=marketplace_product.brand,
+ price=marketplace_product.price,
+ is_digital=marketplace_product.is_digital,
+ product_type=marketplace_product.product_type_enum,
+ # ... all other fields
+)
+```
+
+### 2. Direct Creation (No Marketplace Source)
+
+Stores can create products directly without a marketplace source:
+
+```python
+product = Product(
+ store_id=store.id,
+ marketplace_product_id=None, # No source
+ store_sku="DIRECT_001",
+ brand="MyBrand",
+ price=29.99,
+ is_digital=True,
+ product_type="digital",
+ is_active=True,
+)
+```
+
+---
+
+## Key Fields
+
+### Product Type Fields
+
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `is_digital` | Boolean | `False` | Whether product is digital (no physical shipping) |
+| `product_type` | String(20) | `"physical"` | Product type: physical, digital, service, subscription |
+
+These are **independent columns** on Product, not derived from MarketplaceProduct.
+
+### Source Reference
+
+| Field | Type | Nullable | Description |
+|-------|------|----------|-------------|
+| `marketplace_product_id` | Integer FK | **Yes** | Optional reference to source MarketplaceProduct |
+
+---
+
+## Inventory Handling
+
+Digital and physical products have different inventory behavior:
+
+```python
+@property
+def has_unlimited_inventory(self) -> bool:
+ """Digital products have unlimited inventory."""
+ return self.is_digital
+
+@property
+def total_inventory(self) -> int:
+ """Get total inventory across all locations."""
+ if self.is_digital:
+ return Product.UNLIMITED_INVENTORY # 999999
+ return sum(inv.quantity for inv in self.inventory_entries)
+```
+
+---
+
+## Source Comparison (Display Only)
+
+For products with a marketplace source, we provide comparison info for display:
+
+```python
+def get_source_comparison_info(self) -> dict:
+ """Get current values with source values for comparison.
+
+ Used for "view original source" display feature.
+ """
+ mp = self.marketplace_product
+ return {
+ "price": self.price,
+ "price_source": mp.price if mp else None,
+ "brand": self.brand,
+ "brand_source": mp.brand if mp else None,
+ # ... other fields
+ }
+```
+
+This is **read-only** - there's no mechanism to "reset" to source values.
+
+---
+
+## UI Behavior
+
+### Detail Page
+
+| Product Type | Source Info Card | Edit Button Text |
+|-------------|------------------|------------------|
+| Marketplace-sourced | Shows source info with "View Source" link | "Edit Overrides" |
+| Directly created | Shows "Direct Creation" badge | "Edit Product" |
+
+### Info Banner
+
+- **Marketplace-sourced**: Purple banner - "Store Product Catalog Entry"
+- **Directly created**: Blue banner - "Directly Created Product"
+
+---
+
+## Database Schema
+
+### Product Table Key Columns
+
+```sql
+CREATE TABLE products (
+ id INTEGER PRIMARY KEY,
+ store_id INTEGER NOT NULL REFERENCES stores(id),
+ marketplace_product_id INTEGER REFERENCES marketplace_products(id), -- Nullable!
+
+ -- Product Type (independent columns)
+ is_digital BOOLEAN DEFAULT FALSE,
+ product_type VARCHAR(20) DEFAULT 'physical',
+
+ -- Identifiers
+ store_sku VARCHAR,
+ gtin VARCHAR,
+ gtin_type VARCHAR(10),
+ brand VARCHAR,
+
+ -- Pricing (in cents)
+ price_cents INTEGER,
+ sale_price_cents INTEGER,
+ currency VARCHAR(3) DEFAULT 'EUR',
+ tax_rate_percent INTEGER DEFAULT 17,
+ availability VARCHAR,
+
+ -- Media
+ primary_image_url VARCHAR,
+ additional_images JSON,
+
+ -- Status
+ is_active BOOLEAN DEFAULT TRUE,
+ is_featured BOOLEAN DEFAULT FALSE,
+
+ -- Timestamps
+ created_at TIMESTAMP,
+ updated_at TIMESTAMP
+);
+
+-- Index for product type queries
+CREATE INDEX idx_product_is_digital ON products(is_digital);
+```
+
+---
+
+## Migration History
+
+| Migration | Description |
+|-----------|-------------|
+| `x2c3d4e5f6g7` | Made `marketplace_product_id` nullable |
+| `y3d4e5f6g7h8` | Added `is_digital` and `product_type` columns to products |
+
+---
+
+## API Endpoints
+
+### Create Product (Admin)
+
+```
+POST /api/v1/admin/store-products
+{
+ "store_id": 1,
+ "translations": {
+ "en": {"title": "Product Name", "description": "..."},
+ "fr": {"title": "Nom du produit", "description": "..."}
+ },
+ "store_sku": "SKU001",
+ "brand": "BrandName",
+ "price": 29.99,
+ "is_digital": false,
+ "is_active": true
+}
+```
+
+### Update Product (Admin)
+
+```
+PATCH /api/v1/admin/store-products/{id}
+{
+ "is_digital": true,
+ "price": 39.99,
+ "translations": {
+ "en": {"title": "Updated Name"}
+ }
+}
+```
+
+---
+
+## Testing
+
+Key test scenarios:
+
+1. **Direct Product Creation** - Create without marketplace source
+2. **Digital Product Inventory** - Verify unlimited inventory for digital
+3. **is_digital Column** - Verify it's an independent column, not derived
+4. **Source Comparison** - Verify read-only source info display
+
+See:
+- `tests/unit/models/database/test_product.py`
+- `tests/integration/api/v1/admin/test_store_products.py`
diff --git a/app/modules/catalog/docs/data-model.md b/app/modules/catalog/docs/data-model.md
new file mode 100644
index 00000000..973f63a5
--- /dev/null
+++ b/app/modules/catalog/docs/data-model.md
@@ -0,0 +1,105 @@
+# Catalog Data Model
+
+Entity relationships and database schema for the catalog module.
+
+## Entity Relationship Overview
+
+```
+Store 1──* Product 1──* ProductTranslation
+ │
+ ├──* ProductMedia *──1 MediaFile
+ │
+ ├──? MarketplaceProduct (source)
+ │
+ └──* Inventory (from inventory module)
+```
+
+## Models
+
+### Product
+
+Store-specific product with independent copy pattern from marketplace imports. All monetary values stored as integer cents.
+
+| Field | Type | Constraints | Description |
+|-------|------|-------------|-------------|
+| `id` | Integer | PK | Primary key |
+| `store_id` | Integer | FK, not null | Store ownership |
+| `marketplace_product_id` | Integer | FK, nullable | Optional marketplace source |
+| `store_sku` | String | indexed | Store's internal SKU |
+| `gtin` | String(50) | indexed | EAN/UPC barcode |
+| `gtin_type` | String(20) | nullable | gtin13, gtin14, gtin12, gtin8, isbn13, isbn10 |
+| `price_cents` | Integer | nullable | Gross price in cents |
+| `sale_price_cents` | Integer | nullable | Sale price in cents |
+| `currency` | String(3) | default "EUR" | Currency code |
+| `brand` | String | nullable | Product brand |
+| `condition` | String | nullable | Product condition |
+| `availability` | String | nullable | Availability status |
+| `primary_image_url` | String | nullable | Main product image URL |
+| `additional_images` | JSON | nullable | Array of additional image URLs |
+| `download_url` | String | nullable | Digital product download URL |
+| `license_type` | String(50) | nullable | Digital product license |
+| `tax_rate_percent` | Integer | not null, default 17 | VAT rate (LU: 0, 3, 8, 14, 17) |
+| `supplier` | String(50) | nullable | codeswholesale, internal, etc. |
+| `supplier_product_id` | String | nullable | Supplier's product reference |
+| `cost_cents` | Integer | nullable | Cost to acquire in cents |
+| `margin_percent_x100` | Integer | nullable | Markup × 100 (2550 = 25.5%) |
+| `is_digital` | Boolean | default False, indexed | Digital vs physical |
+| `product_type` | String(20) | default "physical" | physical, digital, service, subscription |
+| `is_featured` | Boolean | default False | Featured flag |
+| `is_active` | Boolean | default True | Active flag |
+| `display_order` | Integer | default 0 | Sort order |
+| `min_quantity` | Integer | default 1 | Min purchase quantity |
+| `max_quantity` | Integer | nullable | Max purchase quantity |
+| `fulfillment_email_template` | String | nullable | Template for digital delivery |
+| `created_at` | DateTime | tz-aware | Record creation time |
+| `updated_at` | DateTime | tz-aware | Record update time |
+
+**Unique Constraint**: `(store_id, marketplace_product_id)`
+**Composite Indexes**: `(store_id, is_active)`, `(store_id, is_featured)`, `(store_id, store_sku)`, `(supplier, supplier_product_id)`
+
+**Key Properties**: `price`, `sale_price`, `cost` (euro converters), `net_price_cents` (gross minus VAT), `vat_amount_cents`, `profit_cents`, `profit_margin_percent`, `total_inventory`, `available_inventory`
+
+### ProductTranslation
+
+Store-specific multilingual content with SEO fields. Independent copy from marketplace translations.
+
+| Field | Type | Constraints | Description |
+|-------|------|-------------|-------------|
+| `id` | Integer | PK | Primary key |
+| `product_id` | Integer | FK, not null, cascade | Parent product |
+| `language` | String(5) | not null | en, fr, de, lb |
+| `title` | String | nullable | Product title |
+| `description` | Text | nullable | Full description |
+| `short_description` | String(500) | nullable | Abbreviated description |
+| `meta_title` | String(70) | nullable | SEO title |
+| `meta_description` | String(160) | nullable | SEO description |
+| `url_slug` | String(255) | nullable | URL-friendly slug |
+| `created_at` | DateTime | tz-aware | Record creation time |
+| `updated_at` | DateTime | tz-aware | Record update time |
+
+**Unique Constraint**: `(product_id, language)`
+
+### ProductMedia
+
+Association between products and media files with usage tracking.
+
+| Field | Type | Constraints | Description |
+|-------|------|-------------|-------------|
+| `id` | Integer | PK | Primary key |
+| `product_id` | Integer | FK, not null, cascade | Product reference |
+| `media_id` | Integer | FK, not null, cascade | Media file reference |
+| `usage_type` | String(50) | default "gallery" | main_image, gallery, variant, thumbnail, swatch |
+| `display_order` | Integer | default 0 | Sort order |
+| `variant_id` | Integer | nullable | Variant reference |
+| `created_at` | DateTime | tz-aware | Record creation time |
+| `updated_at` | DateTime | tz-aware | Record update time |
+
+**Unique Constraint**: `(product_id, media_id, usage_type)`
+
+## Design Patterns
+
+- **Independent copy pattern**: Products are copied from marketplace sources, not linked. Store-specific data diverges independently.
+- **Money as cents**: All prices, costs, margins stored as integer cents
+- **Luxembourg VAT**: Supports all LU rates (0%, 3%, 8%, 14%, 17%)
+- **Multi-type products**: Physical, digital, service, subscription with type-specific fields
+- **SEO per language**: Meta title and description in each translation
diff --git a/app/modules/catalog/docs/index.md b/app/modules/catalog/docs/index.md
new file mode 100644
index 00000000..17948327
--- /dev/null
+++ b/app/modules/catalog/docs/index.md
@@ -0,0 +1,57 @@
+# Product Catalog
+
+Product catalog browsing and search for storefronts.
+
+## Overview
+
+| Aspect | Detail |
+|--------|--------|
+| Code | `catalog` |
+| Classification | Optional |
+| Dependencies | None |
+| Status | Active |
+
+## Features
+
+- `product_catalog` — Product catalog browsing
+- `product_search` — Product search and filtering
+- `product_variants` — Product variant management
+- `product_categories` — Category hierarchy
+- `product_attributes` — Custom product attributes
+- `product_import_export` — Bulk product import/export
+
+## Permissions
+
+| Permission | Description |
+|------------|-------------|
+| `products.view` | View products |
+| `products.create` | Create products |
+| `products.edit` | Edit products |
+| `products.delete` | Delete products |
+| `products.import` | Import products |
+| `products.export` | Export products |
+
+## Data Model
+
+See [Data Model](data-model.md) for full entity relationships and schema.
+
+- **Product** — Store-specific product with pricing, VAT, and supplier fields
+- **ProductTranslation** — Multilingual content with SEO fields
+- **ProductMedia** — Product-media associations with usage types
+
+## API Endpoints
+
+| Method | Path | Description |
+|--------|------|-------------|
+| `*` | `/api/v1/admin/catalog/*` | Admin product management |
+| `*` | `/api/v1/store/catalog/*` | Store product management |
+| `GET` | `/api/v1/storefront/catalog/*` | Public product browsing |
+
+## Configuration
+
+No module-specific configuration.
+
+## Additional Documentation
+
+- [Data Model](data-model.md) — Entity relationships and database schema
+- [Architecture](architecture.md) — Independent product copy pattern and API design
diff --git a/app/modules/checkout/docs/index.md b/app/modules/checkout/docs/index.md
new file mode 100644
index 00000000..f40e4fa2
--- /dev/null
+++ b/app/modules/checkout/docs/index.md
@@ -0,0 +1,41 @@
+# Checkout
+
+Checkout and order creation for storefronts.
+
+## Overview
+
+| Aspect | Detail |
+|--------|--------|
+| Code | `checkout` |
+| Classification | Optional |
+| Dependencies | `cart`, `orders`, `payments`, `customers` |
+| Status | Active |
+
+## Features
+
+- `checkout_flow` — Multi-step checkout process
+- `order_creation` — Cart-to-order conversion
+- `payment_processing` — Payment integration during checkout
+- `checkout_validation` — Address and cart validation
+- `guest_checkout` — Checkout without customer account
+
+## Permissions
+
+| Permission | Description |
+|------------|-------------|
+| `checkout.view_settings` | View checkout settings |
+| `checkout.manage_settings` | Manage checkout configuration |
+
+## Data Model
+
+Checkout is a stateless flow that creates orders — no dedicated models.
+
+## API Endpoints
+
+| Method | Path | Description |
+|--------|------|-------------|
+| `*` | `/api/v1/storefront/checkout/*` | Storefront checkout flow |
+
+## Configuration
+
+No module-specific configuration.
diff --git a/app/modules/cms/docs/architecture.md b/app/modules/cms/docs/architecture.md
new file mode 100644
index 00000000..2650abc4
--- /dev/null
+++ b/app/modules/cms/docs/architecture.md
@@ -0,0 +1,604 @@
+# Content Management System (CMS)
+
+## Overview
+
+The Content Management System allows platform administrators and stores to manage static content pages like About, FAQ, Contact, Shipping, Returns, Privacy Policy, Terms of Service, etc.
+
+**Key Features:**
+- ✅ Platform-level default content
+- ✅ Store-specific overrides
+- ✅ Fallback system (store → platform default)
+- ✅ Rich text content (HTML/Markdown)
+- ✅ SEO metadata
+- ✅ Published/Draft status
+- ✅ Navigation management (footer/header)
+- ✅ Display order control
+
+## Architecture
+
+### Two-Tier Content System
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ CONTENT LOOKUP FLOW │
+└─────────────────────────────────────────────────────────────┘
+
+Request: /about
+
+ 1. Check for store-specific override
+ ↓
+ SELECT * FROM content_pages
+ WHERE store_id = 123 AND slug = 'about' AND is_published = true
+ ↓
+ Found? ✅ Return store content
+ ❌ Continue to step 2
+
+ 2. Check for platform default
+ ↓
+ SELECT * FROM content_pages
+ WHERE store_id IS NULL AND slug = 'about' AND is_published = true
+ ↓
+ Found? ✅ Return platform content
+ ❌ Return 404 or default template
+```
+
+### Database Schema
+
+```sql
+CREATE TABLE content_pages (
+ id SERIAL PRIMARY KEY,
+
+ -- Store association (NULL = platform default)
+ store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE,
+
+ -- Page identification
+ slug VARCHAR(100) NOT NULL, -- about, faq, contact, shipping, returns
+ title VARCHAR(200) NOT NULL,
+
+ -- Content
+ content TEXT NOT NULL, -- HTML or Markdown
+ content_format VARCHAR(20) DEFAULT 'html', -- html, markdown
+
+ -- SEO
+ meta_description VARCHAR(300),
+ meta_keywords VARCHAR(300),
+
+ -- Publishing
+ is_published BOOLEAN DEFAULT FALSE NOT NULL,
+ published_at TIMESTAMP WITH TIME ZONE,
+
+ -- Navigation placement
+ display_order INTEGER DEFAULT 0,
+ show_in_footer BOOLEAN DEFAULT TRUE, -- Quick Links column
+ show_in_header BOOLEAN DEFAULT FALSE, -- Top navigation
+ show_in_legal BOOLEAN DEFAULT FALSE, -- Bottom bar with copyright
+
+ -- Timestamps
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
+
+ -- Author tracking
+ created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
+
+ -- Constraints
+ CONSTRAINT uq_store_slug UNIQUE (store_id, slug)
+);
+
+CREATE INDEX idx_store_published ON content_pages (store_id, is_published);
+CREATE INDEX idx_slug_published ON content_pages (slug, is_published);
+```
+
+## Usage
+
+### Platform Administrator Workflow
+
+**1. Create Platform Default Pages**
+
+Platform admins create default content that all stores inherit:
+
+```bash
+POST /api/v1/admin/content-pages/platform
+{
+ "slug": "about",
+ "title": "About Us",
+ "content": "About Us We are a marketplace...
",
+ "content_format": "html",
+ "meta_description": "Learn more about our marketplace",
+ "is_published": true,
+ "show_in_header": true,
+ "show_in_footer": true,
+ "show_in_legal": false,
+ "display_order": 1
+}
+```
+
+**Common Platform Defaults:**
+- `about` - About Us
+- `contact` - Contact Us
+- `faq` - Frequently Asked Questions
+- `shipping` - Shipping Information
+- `returns` - Return Policy
+- `privacy` - Privacy Policy
+- `terms` - Terms of Service
+- `help` - Help Center
+
+**2. View All Content Pages**
+
+```bash
+GET /api/v1/admin/content-pages/
+GET /api/v1/admin/content-pages/?store_id=123 # Filter by store
+GET /api/v1/admin/content-pages/platform # Only platform defaults
+```
+
+**3. Update Platform Default**
+
+```bash
+PUT /api/v1/admin/content-pages/1
+{
+ "title": "Updated About Us",
+ "content": "About Our Platform ...",
+ "is_published": true
+}
+```
+
+### Store Workflow
+
+**1. View Available Pages**
+
+Stores see their overrides + platform defaults:
+
+```bash
+GET /api/v1/store/{code}/content-pages/
+```
+
+Response:
+```json
+[
+ {
+ "id": 15,
+ "slug": "about",
+ "title": "About Orion", // Store override
+ "is_store_override": true,
+ "is_platform_page": false
+ },
+ {
+ "id": 2,
+ "slug": "shipping",
+ "title": "Shipping Information", // Platform default
+ "is_store_override": false,
+ "is_platform_page": true
+ }
+]
+```
+
+**2. Create Store Override**
+
+Store creates custom "About" page:
+
+```bash
+POST /api/v1/store/{code}/content-pages/
+{
+ "slug": "about",
+ "title": "About Orion",
+ "content": "About Orion We specialize in...
",
+ "is_published": true
+}
+```
+
+This overrides the platform default for this store only.
+
+**3. View Only Store Overrides**
+
+```bash
+GET /api/v1/store/{code}/content-pages/overrides
+```
+
+Shows what the store has customized (excludes platform defaults).
+
+**4. Delete Override (Revert to Platform Default)**
+
+```bash
+DELETE /api/v1/store/{code}/content-pages/15
+```
+
+After deletion, platform default will be shown again.
+
+### Storefront (Public)
+
+**1. Get Page Content**
+
+```bash
+GET /api/v1/storefront/content-pages/about
+```
+
+Automatically uses store context from middleware:
+- Returns store override if exists
+- Falls back to platform default
+- Returns 404 if neither exists
+
+**2. Get Navigation Links**
+
+```bash
+# Get all navigation pages
+GET /api/v1/storefront/content-pages/navigation
+
+# Filter by placement
+GET /api/v1/storefront/content-pages/navigation?header_only=true
+GET /api/v1/storefront/content-pages/navigation?footer_only=true
+GET /api/v1/storefront/content-pages/navigation?legal_only=true
+```
+
+Returns published pages filtered by navigation placement.
+
+## File Structure
+
+```
+app/
+├── models/database/
+│ └── content_page.py ← Database model
+│
+├── services/
+│ └── content_page_service.py ← Business logic
+│
+├── api/v1/
+│ ├── admin/
+│ │ └── content_pages.py ← Admin API endpoints
+│ ├── store/
+│ │ └── content_pages.py ← Store API endpoints
+│ └── storefront/
+│ └── content_pages.py ← Public API endpoints
+│
+└── templates/storefront/
+ ├── about.html ← Content page template
+ ├── faq.html
+ ├── contact.html
+ └── ...
+```
+
+## Template Integration
+
+### Generic Content Page Template
+
+Create a reusable template for all content pages:
+
+```jinja2
+{# app/templates/storefront/content-page.html #}
+{% extends "storefront/base.html" %}
+
+{% block title %}{{ page.title }}{% endblock %}
+
+{% block meta_description %}
+ {{ page.meta_description or page.title }}
+{% endblock %}
+
+{% block content %}
+
+
+ {# Breadcrumbs #}
+
+ Home
+ /
+ {{ page.title }}
+
+
+ {# Page Title #}
+
+ {{ page.title }}
+
+
+ {# Content #}
+
+ {% if page.content_format == 'markdown' %}
+ {{ page.content | markdown }}
+ {% else %}
+ {{ page.content | safe }}
+ {% endif %}
+
+
+ {# Last updated #}
+ {% if page.updated_at %}
+
+ Last updated: {{ page.updated_at }}
+
+ {% endif %}
+
+
+{% endblock %}
+```
+
+### Route Handler
+
+```python
+# app/routes/storefront_pages.py
+
+from app.services.content_page_service import content_page_service
+
+@router.get("/{slug}", response_class=HTMLResponse)
+async def content_page(
+ slug: str,
+ request: Request,
+ db: Session = Depends(get_db)
+):
+ """
+ Generic content page handler.
+
+ Loads content from database with store override support.
+ """
+ store = getattr(request.state, 'store', None)
+ store_id = store.id if store else None
+
+ page = content_page_service.get_page_for_store(
+ db,
+ slug=slug,
+ store_id=store_id,
+ include_unpublished=False
+ )
+
+ if not page:
+ raise HTTPException(status_code=404, detail=f"Page not found: {slug}")
+
+ return templates.TemplateResponse(
+ "storefront/content-page.html",
+ get_storefront_context(request, page=page)
+ )
+```
+
+### Dynamic Footer Navigation
+
+Update footer to load links from database:
+
+```jinja2
+{# app/templates/storefront/base.html #}
+
+
+```
+
+## Best Practices
+
+### 1. Content Formatting
+
+**HTML Content:**
+```html
+About Us
+We are a leading marketplace for...
+
+ Quality products
+ Fast shipping
+ Great support
+
+```
+
+**Markdown Content:**
+```markdown
+# About Us
+
+We are a **leading marketplace** for...
+
+- Quality products
+- Fast shipping
+- Great support
+```
+
+### 2. SEO Optimization
+
+Always provide meta descriptions:
+
+```json
+{
+ "meta_description": "Learn about our marketplace, mission, and values. We connect stores with customers worldwide.",
+ "meta_keywords": "about us, marketplace, e-commerce, mission"
+}
+```
+
+### 3. Draft → Published Workflow
+
+1. Create page with `is_published: false`
+2. Preview using `include_unpublished=true` parameter
+3. Review and edit
+4. Publish with `is_published: true`
+
+### 4. Navigation Management
+
+The CMS supports three navigation placement categories:
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ HEADER (show_in_header=true) │
+│ [Logo] About Us Contact [Login] [Sign Up] │
+├─────────────────────────────────────────────────────────────────┤
+│ │
+│ PAGE CONTENT │
+│ │
+├─────────────────────────────────────────────────────────────────┤
+│ FOOTER (show_in_footer=true) │
+│ ┌──────────────┬──────────────┬────────────┬──────────────┐ │
+│ │ Quick Links │ Platform │ Contact │ Social │ │
+│ │ • About │ • Admin │ • Email │ • Twitter │ │
+│ │ • FAQ │ • Store │ • Phone │ • LinkedIn │ │
+│ │ • Contact │ │ │ │ │
+│ │ • Shipping │ │ │ │ │
+│ └──────────────┴──────────────┴────────────┴──────────────┘ │
+├─────────────────────────────────────────────────────────────────┤
+│ LEGAL BAR (show_in_legal=true) │
+│ © 2025 Orion Privacy Policy │ Terms │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+**Navigation Categories:**
+
+| Category | Field | Location | Typical Pages |
+|----------|-------|----------|---------------|
+| Header | `show_in_header` | Top navigation bar | About, Contact |
+| Footer | `show_in_footer` | Quick Links column | FAQ, Shipping, Returns |
+| Legal | `show_in_legal` | Bottom bar with © | Privacy, Terms |
+
+**Use `display_order` to control link ordering within each category:**
+
+```python
+# Platform defaults with navigation placement
+"about": display_order=1, show_in_header=True, show_in_footer=True
+"contact": display_order=2, show_in_header=True, show_in_footer=True
+"faq": display_order=3, show_in_footer=True
+"shipping": display_order=4, show_in_footer=True
+"returns": display_order=5, show_in_footer=True
+"privacy": display_order=6, show_in_legal=True
+"terms": display_order=7, show_in_legal=True
+```
+
+### 5. Content Reversion
+
+To revert store override back to platform default:
+
+```bash
+# Store deletes their custom page
+DELETE /api/v1/store/{code}/content-pages/15
+
+# Platform default will now be shown automatically
+```
+
+## Common Page Slugs
+
+Standard slugs to implement:
+
+| Slug | Title | Header | Footer | Legal | Order |
+|------|-------|--------|--------|-------|-------|
+| `about` | About Us | ✅ | ✅ | ❌ | 1 |
+| `contact` | Contact Us | ✅ | ✅ | ❌ | 2 |
+| `faq` | FAQ | ❌ | ✅ | ❌ | 3 |
+| `shipping` | Shipping Info | ❌ | ✅ | ❌ | 4 |
+| `returns` | Returns | ❌ | ✅ | ❌ | 5 |
+| `privacy` | Privacy Policy | ❌ | ❌ | ✅ | 6 |
+| `terms` | Terms of Service | ❌ | ❌ | ✅ | 7 |
+| `help` | Help Center | ❌ | ✅ | ❌ | 8 |
+| `size-guide` | Size Guide | ❌ | ❌ | ❌ | - |
+| `careers` | Careers | ❌ | ❌ | ❌ | - |
+| `cookies` | Cookie Policy | ❌ | ❌ | ✅ | 8 |
+
+## Security Considerations
+
+1. **HTML Sanitization**: If using HTML format, sanitize user input to prevent XSS
+2. **Authorization**: Stores can only edit their own pages
+3. **Published Status**: Only published pages visible to public
+4. **Store Isolation**: Stores cannot see/edit other store's content
+
+## Migration Strategy
+
+### Initial Setup
+
+1. **Create Platform Defaults**:
+```bash
+python scripts/seed/create_default_content_pages.py
+```
+
+2. **Migrate Existing Static Templates**:
+- Convert existing HTML templates to database content
+- Preserve existing URLs and SEO
+
+3. **Update Routes**:
+- Add generic content page route handler
+- Remove individual route handlers for each page
+
+## Future Enhancements
+
+Possible improvements:
+
+- **Version History**: Track content changes over time
+- **Rich Text Editor**: WYSIWYG editor in admin/store panel
+- **Image Management**: Upload and insert images
+- **Templates**: Pre-built page templates for common pages
+- **Localization**: Multi-language content support
+- **Scheduled Publishing**: Publish pages at specific times
+- **Content Approval**: Admin review before store pages go live
+
+## API Reference Summary
+
+### Admin Endpoints
+
+```
+GET /api/v1/admin/content-pages/ # List all pages
+GET /api/v1/admin/content-pages/platform # List platform defaults
+POST /api/v1/admin/content-pages/platform # Create platform default
+GET /api/v1/admin/content-pages/{id} # Get specific page
+PUT /api/v1/admin/content-pages/{id} # Update page
+DELETE /api/v1/admin/content-pages/{id} # Delete page
+```
+
+### Store Endpoints
+
+```
+GET /api/v1/store/{code}/content-pages/ # List all (store + platform)
+GET /api/v1/store/{code}/content-pages/overrides # List store overrides only
+GET /api/v1/store/{code}/content-pages/{slug} # Get specific page
+POST /api/v1/store/{code}/content-pages/ # Create store override
+PUT /api/v1/store/{code}/content-pages/{id} # Update store page
+DELETE /api/v1/store/{code}/content-pages/{id} # Delete store page
+```
+
+### Storefront (Public) Endpoints
+
+```
+GET /api/v1/storefront/content-pages/navigation # Get navigation links
+GET /api/v1/storefront/content-pages/{slug} # Get page content
+```
+
+## Example: Complete Workflow
+
+**1. Platform Admin Creates Defaults:**
+```bash
+# Create "About" page
+curl -X POST /api/v1/admin/content-pages/platform \
+ -H "Authorization: Bearer " \
+ -d '{
+ "slug": "about",
+ "title": "About Our Marketplace",
+ "content": "About Default content...
",
+ "is_published": true
+ }'
+```
+
+**2. All Stores See Platform Default:**
+- Store A visits: `store-a.com/about` → Shows platform default
+- Store B visits: `store-b.com/about` → Shows platform default
+
+**3. Store A Creates Override:**
+```bash
+curl -X POST /api/v1/store/store-a/content-pages/ \
+ -H "Authorization: Bearer " \
+ -d '{
+ "slug": "about",
+ "title": "About Store A",
+ "content": "About Store A Custom content...
",
+ "is_published": true
+ }'
+```
+
+**4. Now:**
+- Store A visits: `store-a.com/about` → Shows Store A custom content
+- Store B visits: `store-b.com/about` → Still shows platform default
+
+**5. Store A Reverts to Default:**
+```bash
+curl -X DELETE /api/v1/store/store-a/content-pages/15 \
+ -H "Authorization: Bearer "
+```
+
+**6. Result:**
+- Store A visits: `store-a.com/about` → Shows platform default again
diff --git a/app/modules/cms/docs/data-model.md b/app/modules/cms/docs/data-model.md
new file mode 100644
index 00000000..1265530d
--- /dev/null
+++ b/app/modules/cms/docs/data-model.md
@@ -0,0 +1,115 @@
+# CMS Data Model
+
+Entity relationships and database schema for the CMS module.
+
+## Entity Relationship Overview
+
+```
+Platform 1──* ContentPage
+Store 1──* ContentPage
+Store 1──* MediaFile
+Store 1──1 StoreTheme
+```
+
+## Models
+
+### ContentPage
+
+Multi-language content pages with platform/store hierarchy. Pages can be platform marketing pages, store defaults, or store-specific overrides.
+
+| Field | Type | Constraints | Description |
+|-------|------|-------------|-------------|
+| `id` | Integer | PK | Primary key |
+| `platform_id` | Integer | FK, not null, indexed | Platform this page belongs to |
+| `store_id` | Integer | FK, nullable, indexed | Store association (null = platform/default) |
+| `is_platform_page` | Boolean | not null, default False | Platform marketing page vs store default |
+| `slug` | String(100) | not null, indexed | Page identifier (about, faq, contact, etc.) |
+| `title` | String(200) | not null | Page title |
+| `content` | Text | not null | HTML or Markdown content |
+| `content_format` | String(20) | default "html" | Format: html, markdown |
+| `template` | String(50) | default "default" | Template: default, minimal, modern, full |
+| `sections` | JSON | nullable | Structured homepage sections with i18n |
+| `title_translations` | JSON | nullable | Language-keyed title dict {en, fr, de, lb} |
+| `content_translations` | JSON | nullable | Language-keyed content dict {en, fr, de, lb} |
+| `meta_description` | String(300) | nullable | SEO meta description |
+| `meta_keywords` | String(300) | nullable | SEO keywords |
+| `is_published` | Boolean | not null, default False | Publication status |
+| `published_at` | DateTime | nullable, tz-aware | Publication timestamp |
+| `display_order` | Integer | not null, default 0 | Menu/footer ordering |
+| `show_in_footer` | Boolean | not null, default True | Footer visibility |
+| `show_in_header` | Boolean | not null, default False | Header navigation |
+| `show_in_legal` | Boolean | not null, default False | Legal bar visibility |
+| `created_at` | DateTime | tz-aware | Record creation time |
+| `updated_at` | DateTime | tz-aware | Record update time |
+| `created_by` | Integer | FK, nullable | Creator user ID |
+| `updated_by` | Integer | FK, nullable | Updater user ID |
+
+**Unique Constraint**: `(platform_id, store_id, slug)`
+**Composite Indexes**: `(platform_id, store_id, is_published)`, `(platform_id, slug, is_published)`, `(platform_id, is_platform_page)`
+
+**Page tiers**: platform → store_default (store_id null, not platform) → store_override (store_id set)
+
+### MediaFile
+
+Media files (images, videos, documents) managed per-store.
+
+| Field | Type | Constraints | Description |
+|-------|------|-------------|-------------|
+| `id` | Integer | PK | Primary key |
+| `store_id` | Integer | FK, not null, indexed | Store owner |
+| `filename` | String(255) | not null, indexed | UUID-based stored filename |
+| `original_filename` | String(255) | nullable | Original upload name |
+| `file_path` | String(500) | not null | Relative path from uploads/ |
+| `media_type` | String(20) | not null | image, video, document |
+| `mime_type` | String(100) | nullable | MIME type |
+| `file_size` | Integer | nullable | File size in bytes |
+| `width` | Integer | nullable | Image/video width in pixels |
+| `height` | Integer | nullable | Image/video height in pixels |
+| `thumbnail_path` | String(500) | nullable | Path to thumbnail |
+| `alt_text` | String(500) | nullable | Alt text for images |
+| `description` | Text | nullable | File description |
+| `folder` | String(100) | default "general" | Folder: products, general, etc. |
+| `tags` | JSON | nullable | Tags for categorization |
+| `extra_metadata` | JSON | nullable | Additional metadata (EXIF, etc.) |
+| `is_optimized` | Boolean | default False | Optimization status |
+| `optimized_size` | Integer | nullable | Size after optimization |
+| `usage_count` | Integer | default 0 | Usage tracking |
+| `created_at` | DateTime | tz-aware | Record creation time |
+| `updated_at` | DateTime | tz-aware | Record update time |
+
+**Composite Indexes**: `(store_id, folder)`, `(store_id, media_type)`
+
+### StoreTheme
+
+Per-store theme configuration including colors, fonts, layout, and branding. 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 |
+| `theme_name` | String(100) | default "default" | Preset: default, modern, classic, minimal, vibrant |
+| `is_active` | Boolean | default True | Theme active status |
+| `colors` | JSON | default {...} | Color scheme: primary, secondary, accent, background, text, border |
+| `font_family_heading` | String(100) | default "Inter, sans-serif" | Heading font |
+| `font_family_body` | String(100) | default "Inter, sans-serif" | Body font |
+| `logo_url` | String(500) | nullable | Store logo path |
+| `logo_dark_url` | String(500) | nullable | Dark mode logo |
+| `favicon_url` | String(500) | nullable | Favicon path |
+| `banner_url` | String(500) | nullable | Homepage banner |
+| `layout_style` | String(50) | default "grid" | Layout: grid, list, masonry |
+| `header_style` | String(50) | default "fixed" | Header: fixed, static, transparent |
+| `product_card_style` | String(50) | default "modern" | Card: modern, classic, minimal |
+| `custom_css` | Text | nullable | Custom CSS overrides |
+| `social_links` | JSON | default {} | Social media URLs |
+| `meta_title_template` | String(200) | nullable | SEO title template |
+| `meta_description` | Text | nullable | SEO meta description |
+| `created_at` | DateTime | tz-aware | Record creation time |
+| `updated_at` | DateTime | tz-aware | Record update time |
+
+## Design Patterns
+
+- **Three-tier content hierarchy**: Platform pages → store defaults → store overrides
+- **JSON translations**: Title and content translations stored as JSON dicts with language keys
+- **Media organization**: Files organized by store and folder with type classification
+- **Theme presets**: Named presets with full customization via JSON color scheme and CSS overrides
+- **SEO support**: Meta description, keywords, and title templates on pages and themes
diff --git a/app/modules/cms/docs/email-templates-guide.md b/app/modules/cms/docs/email-templates-guide.md
new file mode 100644
index 00000000..6bfee6d9
--- /dev/null
+++ b/app/modules/cms/docs/email-templates-guide.md
@@ -0,0 +1,287 @@
+# Email Templates Guide
+
+## Overview
+
+The Orion platform provides a comprehensive email template system that allows:
+
+- **Platform Administrators**: Manage all email templates across the platform
+- **Stores**: Customize customer-facing emails with their own branding
+
+This guide covers how to use the email template system from both perspectives.
+
+---
+
+## For Stores
+
+### Accessing Email Templates
+
+1. Log in to your store dashboard
+2. Navigate to **Settings** > **Email Templates** in the sidebar
+3. You'll see a list of all customizable email templates
+
+### Understanding Template Status
+
+Each template shows its customization status:
+
+| Status | Description |
+|--------|-------------|
+| **Platform Default** | Using the standard Orion template |
+| **Customized** | You have created a custom version |
+| Language badges (green) | Languages where you have customizations |
+
+### Customizing a Template
+
+1. Click on any template to open the edit modal
+2. Select the language tab you want to customize (EN, FR, DE, LB)
+3. Edit the following fields:
+ - **Subject**: The email subject line
+ - **HTML Body**: The rich HTML content
+ - **Plain Text Body**: Fallback for email clients that don't support HTML
+
+4. Click **Save** to save your customization
+
+### Template Variables
+
+Templates use special variables that are automatically replaced with actual values. Common variables include:
+
+| Variable | Description |
+|----------|-------------|
+| `{{ customer_name }}` | Customer's first name |
+| `{{ order_number }}` | Order reference number |
+| `{{ store_name }}` | Your store name |
+| `{{ platform_name }}` | Platform name (Orion or your whitelabel name) |
+
+Each template shows its available variables in the reference panel.
+
+### Previewing Templates
+
+Before saving, you can preview your template:
+
+1. Click **Preview** in the edit modal
+2. A preview window shows how the email will look
+3. Sample data is used for all variables
+
+### Testing Templates
+
+To send a test email:
+
+1. Click **Send Test Email** in the edit modal
+2. Enter your email address
+3. Click **Send**
+4. Check your inbox to see the actual email
+
+### Reverting to Platform Default
+
+If you want to remove your customization and use the platform default:
+
+1. Open the template edit modal
+2. Click **Revert to Default**
+3. Confirm the action
+
+Your customization will be deleted and the platform template will be used.
+
+### Available Templates for Stores
+
+| Template | Category | Description |
+|----------|----------|-------------|
+| Welcome Email | AUTH | Sent when a customer registers |
+| Password Reset | AUTH | Password reset link |
+| Order Confirmation | ORDERS | Sent after order placement |
+| Shipping Notification | ORDERS | Sent when order is shipped |
+
+**Note:** Billing and subscription emails are platform-only and cannot be customized.
+
+---
+
+## For Platform Administrators
+
+### Accessing Email Templates
+
+1. Log in to the admin dashboard
+2. Navigate to **System** > **Email Templates** in the sidebar
+3. You'll see all platform templates grouped by category
+
+### Template Categories
+
+| Category | Description | Store Override |
+|----------|-------------|-----------------|
+| AUTH | Authentication emails | Allowed |
+| ORDERS | Order-related emails | Allowed |
+| BILLING | Subscription/payment emails | **Not Allowed** |
+| SYSTEM | System notifications | Allowed |
+| MARKETING | Promotional emails | Allowed |
+
+### Editing Platform Templates
+
+1. Click on any template to open the edit modal
+2. Select the language tab (EN, FR, DE, LB)
+3. Edit the subject and body content
+4. Click **Save**
+
+**Important:** Changes to platform templates affect:
+- All stores who haven't customized the template
+- New stores automatically
+
+### Creating New Templates
+
+To add a new template:
+
+1. Use the database seed script or migration
+2. Define the template code, category, and languages
+3. Set `is_platform_only` if stores shouldn't override it
+
+### Viewing Email Logs
+
+To see email delivery history:
+
+1. Open a template
+2. Click **View Logs**
+3. See recent emails sent using this template
+
+Logs show:
+- Recipient email
+- Send date/time
+- Delivery status
+- Store (if applicable)
+
+### Template Best Practices
+
+1. **Use all 4 languages**: Provide content in EN, FR, DE, and LB
+2. **Test before publishing**: Always send test emails
+3. **Include plain text**: Not all email clients support HTML
+4. **Use consistent branding**: Follow Orion brand guidelines
+5. **Keep subjects short**: Under 60 characters for mobile
+
+---
+
+## Language Resolution
+
+When sending an email, the system determines the language in this order:
+
+1. **Customer's preferred language** (if set in their profile)
+2. **Store's storefront language** (if customer doesn't have preference)
+3. **Platform default** (French - "fr")
+
+### Template Resolution for Stores
+
+1. System checks if store has a custom override
+2. If yes, uses store's template
+3. If no, falls back to platform template
+4. If requested language unavailable, falls back to English
+
+---
+
+## Branding
+
+### Standard Stores
+
+Standard stores' emails include Orion branding:
+- Orion logo in header
+- "Powered by Orion" footer
+
+### Whitelabel Stores
+
+Enterprise-tier stores with whitelabel enabled:
+- No Orion branding
+- Store's logo in header
+- Custom footer (if configured)
+
+---
+
+## Email Template Variables Reference
+
+### Authentication Templates
+
+#### signup_welcome
+```
+{{ first_name }} - Customer's first name
+{{ merchant_name }} - Store merchant name
+{{ email }} - Customer's email
+{{ login_url }} - Link to login page
+{{ trial_days }} - Trial period length
+{{ tier_name }} - Subscription tier
+```
+
+#### password_reset
+```
+{{ customer_name }} - Customer's name
+{{ reset_link }} - Password reset URL
+{{ expiry_hours }} - Link expiration time
+```
+
+### Order Templates
+
+#### order_confirmation
+```
+{{ customer_name }} - Customer's name
+{{ order_number }} - Order reference
+{{ order_total }} - Order total amount
+{{ order_items_count }} - Number of items
+{{ order_date }} - Order date
+{{ shipping_address }} - Delivery address
+```
+
+### Common Variables (All Templates)
+
+```
+{{ platform_name }} - "Orion" or whitelabel name
+{{ platform_logo_url }} - Platform logo URL
+{{ support_email }} - Support email address
+{{ store_name }} - Store's business name
+{{ store_logo_url }} - Store's logo URL
+```
+
+---
+
+## Troubleshooting
+
+### Email Not Received
+
+1. Check spam/junk folder
+2. Verify email address is correct
+3. Check email logs in admin dashboard
+4. Verify SMTP configuration
+
+### Template Not Applying
+
+1. Clear browser cache
+2. Verify the correct language is selected
+3. Check if store override exists
+4. Verify template is not platform-only
+
+### Variables Not Replaced
+
+1. Check variable spelling (case-sensitive)
+2. Ensure variable is available for this template
+3. Wrap variables in `{{ }}` syntax
+4. Check for typos in variable names
+
+---
+
+## API Reference
+
+For developers integrating with the email system:
+
+### Sending a Template Email
+
+```python
+from app.services.email_service import EmailService
+
+email_service = EmailService(db)
+email_service.send_template(
+ template_code="order_confirmation",
+ to_email="customer@example.com",
+ to_name="John Doe",
+ language="fr",
+ variables={
+ "customer_name": "John",
+ "order_number": "ORD-12345",
+ "order_total": "99.99",
+ },
+ store_id=store.id,
+ related_type="order",
+ related_id=order.id,
+)
+```
+
+See [Email Templates Architecture](../cms/email-templates.md) for full technical documentation.
diff --git a/app/modules/cms/docs/email-templates.md b/app/modules/cms/docs/email-templates.md
new file mode 100644
index 00000000..46d12154
--- /dev/null
+++ b/app/modules/cms/docs/email-templates.md
@@ -0,0 +1,458 @@
+# Email Template System
+
+## Overview
+
+The email template system provides comprehensive email customization for the Orion platform with the following features:
+
+- **Platform-level templates** with store overrides
+- **Orion branding** by default (removed for Enterprise whitelabel tier)
+- **Platform-only templates** that cannot be overridden (billing, subscriptions)
+- **Admin UI** for editing platform templates
+- **Store UI** for customizing customer-facing emails
+- **4-language support** (en, fr, de, lb)
+- **Smart language resolution** (customer → store → platform default)
+
+---
+
+## Architecture
+
+### Database Models
+
+#### EmailTemplate (Platform Templates)
+**File:** `models/database/email.py`
+
+| Column | Type | Description |
+|--------|------|-------------|
+| `id` | Integer | Primary key |
+| `code` | String(100) | Unique template identifier |
+| `language` | String(5) | Language code (en, fr, de, lb) |
+| `name` | String(255) | Human-readable name |
+| `description` | Text | Template description |
+| `category` | Enum | AUTH, ORDERS, BILLING, SYSTEM, MARKETING |
+| `subject` | String(500) | Email subject line (Jinja2) |
+| `body_html` | Text | HTML body (Jinja2) |
+| `body_text` | Text | Plain text body (Jinja2) |
+| `variables` | JSON | List of available variables |
+| `is_platform_only` | Boolean | Cannot be overridden by stores |
+| `required_variables` | Text | Comma-separated required variables |
+
+**Key Methods:**
+- `get_by_code_and_language(db, code, language)` - Get specific template
+- `get_overridable_templates(db)` - Get templates stores can customize
+
+#### StoreEmailTemplate (Store Overrides)
+**File:** `models/database/store_email_template.py`
+
+| Column | Type | Description |
+|--------|------|-------------|
+| `id` | Integer | Primary key |
+| `store_id` | Integer | FK to stores.id |
+| `template_code` | String(100) | References EmailTemplate.code |
+| `language` | String(5) | Language code |
+| `name` | String(255) | Custom name (optional) |
+| `subject` | String(500) | Custom subject |
+| `body_html` | Text | Custom HTML body |
+| `body_text` | Text | Custom plain text body |
+| `created_at` | DateTime | Creation timestamp |
+| `updated_at` | DateTime | Last update timestamp |
+
+**Key Methods:**
+- `get_override(db, store_id, code, language)` - Get store override
+- `create_or_update(db, store_id, code, language, ...)` - Upsert override
+- `delete_override(db, store_id, code, language)` - Revert to platform default
+- `get_all_overrides_for_store(db, store_id)` - List all store overrides
+
+### Unique Constraint
+```sql
+UNIQUE (store_id, template_code, language)
+```
+
+---
+
+## Email Template Service
+
+**File:** `app/services/email_template_service.py`
+
+The `EmailTemplateService` encapsulates all email template business logic, keeping API endpoints clean and focused on request/response handling.
+
+### Admin Methods
+
+| Method | Description |
+|--------|-------------|
+| `list_platform_templates()` | List all platform templates grouped by code |
+| `get_template_categories()` | Get list of template categories |
+| `get_platform_template(code)` | Get template with all language versions |
+| `update_platform_template(code, language, data)` | Update platform template content |
+| `preview_template(code, language, variables)` | Generate preview with sample data |
+| `get_template_logs(code, limit)` | Get email logs for template |
+
+### Store Methods
+
+| Method | Description |
+|--------|-------------|
+| `list_overridable_templates(store_id)` | List templates store can customize |
+| `get_store_template(store_id, code, language)` | Get template (override or platform default) |
+| `create_or_update_store_override(store_id, code, language, data)` | Save store customization |
+| `delete_store_override(store_id, code, language)` | Revert to platform default |
+| `preview_store_template(store_id, code, language, variables)` | Preview with store branding |
+
+### Usage Example
+
+```python
+from app.services.email_template_service import EmailTemplateService
+
+service = EmailTemplateService(db)
+
+# List templates for admin
+templates = service.list_platform_templates()
+
+# Get store's view of a template
+template_data = service.get_store_template(store_id, "order_confirmation", "fr")
+
+# Create store override
+service.create_or_update_store_override(
+ store_id=store.id,
+ code="order_confirmation",
+ language="fr",
+ subject="Votre commande {{ order_number }}",
+ body_html="...",
+ body_text="Plain text...",
+)
+```
+
+---
+
+## Email Service
+
+**File:** `app/services/email_service.py`
+
+### Language Resolution
+
+Priority order for determining email language:
+
+1. **Customer preferred language** (if customer exists)
+2. **Store storefront language** (store.storefront_language)
+3. **Platform default** (`en`)
+
+```python
+def resolve_language(
+ self,
+ customer_id: int | None,
+ store_id: int | None,
+ explicit_language: str | None = None
+) -> str
+```
+
+### Template Resolution
+
+```python
+def resolve_template(
+ self,
+ template_code: str,
+ language: str,
+ store_id: int | None = None
+) -> ResolvedTemplate
+```
+
+Resolution order:
+1. If `store_id` provided and template **not** platform-only:
+ - Look for `StoreEmailTemplate` override
+ - Fall back to platform `EmailTemplate`
+2. If no store or platform-only:
+ - Use platform `EmailTemplate`
+3. Language fallback: `requested_language` → `en`
+
+### Branding Resolution
+
+```python
+def get_branding(self, store_id: int | None) -> BrandingContext
+```
+
+| Scenario | Platform Name | Platform Logo |
+|----------|--------------|---------------|
+| No store | Orion | Orion logo |
+| Standard store | Orion | Orion logo |
+| Whitelabel store | Store name | Store logo |
+
+Whitelabel is determined by the `white_label` feature flag on the store.
+
+---
+
+## API Endpoints
+
+### Admin API
+
+**File:** `app/api/v1/admin/email_templates.py`
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| GET | `/api/v1/admin/email-templates` | List all platform templates |
+| GET | `/api/v1/admin/email-templates/categories` | Get template categories |
+| GET | `/api/v1/admin/email-templates/{code}` | Get template (all languages) |
+| GET | `/api/v1/admin/email-templates/{code}/{language}` | Get specific language version |
+| PUT | `/api/v1/admin/email-templates/{code}/{language}` | Update template |
+| POST | `/api/v1/admin/email-templates/{code}/preview` | Preview with sample data |
+| POST | `/api/v1/admin/email-templates/{code}/test` | Send test email |
+| GET | `/api/v1/admin/email-templates/{code}/logs` | View email logs for template |
+
+### Store API
+
+**File:** `app/api/v1/store/email_templates.py`
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| GET | `/api/v1/store/email-templates` | List overridable templates |
+| GET | `/api/v1/store/email-templates/{code}` | Get template with override status |
+| GET | `/api/v1/store/email-templates/{code}/{language}` | Get specific language (override or default) |
+| PUT | `/api/v1/store/email-templates/{code}/{language}` | Create/update override |
+| DELETE | `/api/v1/store/email-templates/{code}/{language}` | Reset to platform default |
+| POST | `/api/v1/store/email-templates/{code}/preview` | Preview with store branding |
+| POST | `/api/v1/store/email-templates/{code}/test` | Send test email |
+
+---
+
+## User Interface
+
+### Admin UI
+
+**Page:** `/admin/email-templates`
+**Template:** `app/templates/admin/email-templates.html`
+**JavaScript:** `static/admin/js/email-templates.js`
+
+Features:
+- Template list with category filtering
+- Edit modal with language tabs (en, fr, de, lb)
+- Platform-only indicator badge
+- Variable reference panel
+- HTML preview in iframe
+- Send test email functionality
+
+### Store UI
+
+**Page:** `/store/{store_code}/email-templates`
+**Template:** `app/templates/store/email-templates.html`
+**JavaScript:** `static/store/js/email-templates.js`
+
+Features:
+- List of overridable templates with customization status
+- Language override badges (green = customized)
+- Edit modal with:
+ - Language tabs
+ - Source indicator (store override vs platform default)
+ - Platform template reference
+ - Revert to default button
+- Preview and test email functionality
+
+---
+
+## Template Categories
+
+| Category | Description | Platform-Only |
+|----------|-------------|---------------|
+| AUTH | Authentication emails (welcome, password reset) | No |
+| ORDERS | Order-related emails (confirmation, shipped) | No |
+| BILLING | Subscription/payment emails | Yes |
+| SYSTEM | System emails (team invites, alerts) | No |
+| MARKETING | Marketing/promotional emails | No |
+
+---
+
+## Available Templates
+
+### Customer-Facing (Overridable)
+
+| Code | Category | Languages | Description |
+|------|----------|-----------|-------------|
+| `signup_welcome` | AUTH | en, fr, de, lb | Welcome email after store signup |
+| `order_confirmation` | ORDERS | en, fr, de, lb | Order confirmation to customer |
+| `password_reset` | AUTH | en, fr, de, lb | Password reset link |
+| `team_invite` | SYSTEM | en | Team member invitation |
+
+### Platform-Only (Not Overridable)
+
+| Code | Category | Languages | Description |
+|------|----------|-----------|-------------|
+| `subscription_welcome` | BILLING | en | Subscription confirmation |
+| `payment_failed` | BILLING | en | Failed payment notification |
+| `subscription_cancelled` | BILLING | en | Cancellation confirmation |
+| `trial_ending` | BILLING | en | Trial ending reminder |
+
+---
+
+## Template Variables
+
+### Common Variables (Injected Automatically)
+
+| Variable | Description |
+|----------|-------------|
+| `platform_name` | "Orion" or store name (whitelabel) |
+| `platform_logo_url` | Platform logo URL |
+| `support_email` | Support email address |
+| `store_name` | Store business name |
+| `store_logo_url` | Store logo URL |
+
+### Template-Specific Variables
+
+#### signup_welcome
+- `first_name`, `merchant_name`, `email`, `store_code`
+- `login_url`, `trial_days`, `tier_name`
+
+#### order_confirmation
+- `customer_name`, `order_number`, `order_total`
+- `order_items_count`, `order_date`, `shipping_address`
+
+#### password_reset
+- `customer_name`, `reset_link`, `expiry_hours`
+
+#### team_invite
+- `invitee_name`, `inviter_name`, `store_name`
+- `role`, `accept_url`, `expires_in_days`
+
+---
+
+## Migration
+
+**File:** `alembic/versions/u9c0d1e2f3g4_add_store_email_templates.py`
+
+Run migration:
+```bash
+alembic upgrade head
+```
+
+The migration:
+1. Adds `is_platform_only` and `required_variables` columns to `email_templates`
+2. Creates `store_email_templates` table
+3. Adds unique constraint on `(store_id, template_code, language)`
+4. Creates indexes for performance
+
+---
+
+## Seeding Templates
+
+**File:** `scripts/seed/seed_email_templates.py`
+
+Run seed script:
+```bash
+python scripts/seed/seed_email_templates.py
+```
+
+The script:
+- Creates/updates all platform templates
+- Supports all 4 languages for customer-facing templates
+- Sets `is_platform_only` flag for billing templates
+
+---
+
+## Security Considerations
+
+1. **XSS Prevention**: HTML templates are rendered server-side with Jinja2 escaping
+2. **Access Control**: Stores can only view/edit their own overrides
+3. **Platform-only Protection**: API enforces `is_platform_only` flag
+4. **Template Validation**: Jinja2 syntax validated before save
+5. **Rate Limiting**: Test email sending subject to rate limits
+6. **Token Hashing**: Password reset tokens stored as SHA256 hashes
+
+---
+
+## Usage Examples
+
+### Sending a Template Email
+
+```python
+from app.services.email_service import EmailService
+
+email_svc = EmailService(db)
+email_log = email_svc.send_template(
+ template_code="order_confirmation",
+ to_email="customer@example.com",
+ variables={
+ "customer_name": "John Doe",
+ "order_number": "ORD-12345",
+ "order_total": "€99.99",
+ "order_items_count": "3",
+ "order_date": "2024-01-15",
+ "shipping_address": "123 Main St, Luxembourg"
+ },
+ store_id=store.id, # Optional: enables store override lookup
+ customer_id=customer.id, # Optional: for language resolution
+ language="fr" # Optional: explicit language override
+)
+```
+
+### Creating a Store Override
+
+```python
+from models.database.store_email_template import StoreEmailTemplate
+
+override = StoreEmailTemplate.create_or_update(
+ db=db,
+ store_id=store.id,
+ template_code="order_confirmation",
+ language="fr",
+ subject="Confirmation de votre commande {{ order_number }}",
+ body_html="...",
+ body_text="Plain text version..."
+)
+db.commit()
+```
+
+### Reverting to Platform Default
+
+```python
+StoreEmailTemplate.delete_override(
+ db=db,
+ store_id=store.id,
+ template_code="order_confirmation",
+ language="fr"
+)
+db.commit()
+```
+
+---
+
+## File Structure
+
+```
+├── alembic/versions/
+│ └── u9c0d1e2f3g4_add_store_email_templates.py
+├── app/
+│ ├── api/v1/
+│ │ ├── admin/
+│ │ │ └── email_templates.py
+│ │ └── store/
+│ │ └── email_templates.py
+│ ├── routes/
+│ │ ├── admin_pages.py (route added)
+│ │ └── store_pages.py (route added)
+│ ├── services/
+│ │ ├── email_service.py (enhanced)
+│ │ └── email_template_service.py (new - business logic)
+│ └── templates/
+│ ├── admin/
+│ │ ├── email-templates.html
+│ │ └── partials/sidebar.html (link added)
+│ └── store/
+│ ├── email-templates.html
+│ └── partials/sidebar.html (link added)
+├── models/
+│ ├── database/
+│ │ ├── email.py (enhanced)
+│ │ └── store_email_template.py
+│ └── schema/
+│ └── email.py
+├── scripts/
+│ └── seed_email_templates.py (enhanced)
+└── static/
+ ├── admin/js/
+ │ └── email-templates.js
+ └── store/js/
+ └── email-templates.js
+```
+
+---
+
+## Related Documentation
+
+- [Email Templates User Guide](email-templates-guide.md) - How to use the email template system
+- [Password Reset Implementation](../../implementation/password-reset-implementation.md) - Password reset feature using email templates
+- [Architecture Fixes (January 2026)](../../development/architecture-fixes-2026-01.md) - Architecture validation fixes
diff --git a/app/modules/cms/docs/implementation.md b/app/modules/cms/docs/implementation.md
new file mode 100644
index 00000000..5791e37c
--- /dev/null
+++ b/app/modules/cms/docs/implementation.md
@@ -0,0 +1,414 @@
+# CMS Implementation Guide
+
+## Quick Start
+
+This guide shows you how to implement the Content Management System for static pages.
+
+## What Was Implemented
+
+✅ **Database Model**: `models/database/content_page.py`
+✅ **Service Layer**: `app/services/content_page_service.py`
+✅ **Admin API**: `app/api/v1/admin/content_pages.py`
+✅ **Store API**: `app/api/v1/store/content_pages.py`
+✅ **Storefront API**: `app/api/v1/storefront/content_pages.py`
+✅ **Documentation**: Full CMS documentation in `docs/features/content-management-system.md`
+
+## Next Steps to Activate
+
+### 1. Create Database Migration
+
+```bash
+# Create Alembic migration
+alembic revision --autogenerate -m "Add content_pages table"
+
+# Review the generated migration in alembic/versions/
+
+# Run migration
+alembic upgrade head
+```
+
+### 2. Add Relationship to Store Model
+
+Edit `models/database/store.py` and add this relationship:
+
+```python
+# Add this import
+from sqlalchemy.orm import relationship
+
+# Add this relationship to Store class
+content_pages = relationship("ContentPage", back_populates="store", cascade="all, delete-orphan")
+```
+
+### 3. Register API Routers
+
+Edit the appropriate router files to include the new endpoints:
+
+**Admin Router** (`app/api/v1/admin/__init__.py`):
+```python
+from app.api.v1.admin import content_pages
+
+api_router.include_router(
+ content_pages.router,
+ prefix="/content-pages",
+ tags=["admin-content-pages"]
+)
+```
+
+**Store Router** (`app/api/v1/store/__init__.py`):
+```python
+from app.api.v1.store import content_pages
+
+api_router.include_router(
+ content_pages.router,
+ prefix="/{store_code}/content-pages",
+ tags=["store-content-pages"]
+)
+```
+
+**Storefront Router** (`app/api/v1/storefront/__init__.py` or create if doesn't exist):
+```python
+from app.api.v1.storefront import content_pages
+
+api_router.include_router(
+ content_pages.router,
+ prefix="/content-pages",
+ tags=["storefront-content-pages"]
+)
+```
+
+### 4. Update Storefront Routes to Use CMS
+
+Edit `app/routes/storefront_pages.py` to add a generic content page handler:
+
+```python
+from app.services.content_page_service import content_page_service
+
+@router.get("/{slug}", response_class=HTMLResponse, include_in_schema=False)
+async def generic_content_page(
+ slug: str,
+ request: Request,
+ db: Session = Depends(get_db)
+):
+ """
+ Generic content page handler.
+ Handles: /about, /faq, /contact, /shipping, /returns, /privacy, /terms, etc.
+ """
+ store = getattr(request.state, 'store', None)
+ store_id = store.id if store else None
+
+ page = content_page_service.get_page_for_store(
+ db,
+ slug=slug,
+ store_id=store_id,
+ include_unpublished=False
+ )
+
+ if not page:
+ raise HTTPException(status_code=404, detail=f"Page not found: {slug}")
+
+ return templates.TemplateResponse(
+ "storefront/content-page.html",
+ get_storefront_context(request, page=page)
+ )
+```
+
+### 5. Create Generic Content Page Template
+
+Create `app/templates/storefront/content-page.html`:
+
+```jinja2
+{# app/templates/storefront/content-page.html #}
+{% extends "storefront/base.html" %}
+
+{% block title %}{{ page.title }}{% endblock %}
+
+{% block meta_description %}
+ {{ page.meta_description or page.title }}
+{% endblock %}
+
+{% block content %}
+
+
+ {# Breadcrumbs #}
+
+ Home
+ /
+ {{ page.title }}
+
+
+ {# Page Title #}
+
+ {{ page.title }}
+
+
+ {# Content #}
+
+ {{ page.content | safe }}
+
+
+ {# Last updated #}
+ {% if page.updated_at %}
+
+ Last updated: {{ page.updated_at.strftime('%B %d, %Y') }}
+
+ {% endif %}
+
+
+{% endblock %}
+```
+
+### 6. Update Footer to Load Navigation Dynamically
+
+Edit `app/templates/storefront/base.html` to load navigation from database.
+
+First, update the context helper to include footer pages:
+
+```python
+# app/routes/storefront_pages.py
+
+def get_storefront_context(request: Request, **extra_context) -> dict:
+ # ... existing code ...
+
+ # Load footer navigation pages
+ db = next(get_db())
+ try:
+ footer_pages = content_page_service.list_pages_for_store(
+ db,
+ store_id=store.id if store else None,
+ include_unpublished=False,
+ footer_only=True
+ )
+ finally:
+ db.close()
+
+ context = {
+ "request": request,
+ "store": store,
+ "theme": theme,
+ "clean_path": clean_path,
+ "access_method": access_method,
+ "base_url": base_url,
+ "footer_pages": footer_pages, # Add this
+ **extra_context
+ }
+
+ return context
+```
+
+Then update the footer template:
+
+```jinja2
+{# app/templates/storefront/base.html - Footer section #}
+
+
+```
+
+### 7. Create Default Platform Pages (Script)
+
+Create `scripts/seed/create_default_content_pages.py`:
+
+```python
+#!/usr/bin/env python3
+"""Create default platform content pages."""
+
+from sqlalchemy.orm import Session
+from app.core.database import SessionLocal
+from app.services.content_page_service import content_page_service
+
+def create_defaults():
+ db: Session = SessionLocal()
+
+ try:
+ # About Us
+ content_page_service.create_page(
+ db,
+ slug="about",
+ title="About Us",
+ content="""
+ Welcome to Our Marketplace
+ We connect quality stores with customers worldwide.
+ Our mission is to provide a seamless shopping experience...
+ """,
+ is_published=True,
+ show_in_footer=True,
+ display_order=1
+ )
+
+ # Shipping Information
+ content_page_service.create_page(
+ db,
+ slug="shipping",
+ title="Shipping Information",
+ content="""
+ Shipping Policy
+ We offer fast and reliable shipping...
+ """,
+ is_published=True,
+ show_in_footer=True,
+ display_order=2
+ )
+
+ # Returns
+ content_page_service.create_page(
+ db,
+ slug="returns",
+ title="Returns & Refunds",
+ content="""
+ Return Policy
+ 30-day return policy on all items...
+ """,
+ is_published=True,
+ show_in_footer=True,
+ display_order=3
+ )
+
+ # Privacy Policy
+ content_page_service.create_page(
+ db,
+ slug="privacy",
+ title="Privacy Policy",
+ content="""
+ Privacy Policy
+ Your privacy is important to us...
+ """,
+ is_published=True,
+ show_in_footer=True,
+ display_order=4
+ )
+
+ # Terms of Service
+ content_page_service.create_page(
+ db,
+ slug="terms",
+ title="Terms of Service",
+ content="""
+ Terms of Service
+ By using our platform, you agree to...
+ """,
+ is_published=True,
+ show_in_footer=True,
+ display_order=5
+ )
+
+ # Contact
+ content_page_service.create_page(
+ db,
+ slug="contact",
+ title="Contact Us",
+ content="""
+ Get in Touch
+ Have questions? We'd love to hear from you!
+ Email: support@example.com
+ """,
+ is_published=True,
+ show_in_footer=True,
+ display_order=6
+ )
+
+ # FAQ
+ content_page_service.create_page(
+ db,
+ slug="faq",
+ title="Frequently Asked Questions",
+ content="""
+ FAQ
+ How do I place an order?
+ Simply browse our products...
+ """,
+ is_published=True,
+ show_in_footer=True,
+ display_order=7
+ )
+
+ print("✅ Created default content pages successfully")
+
+ except Exception as e:
+ print(f"❌ Error: {e}")
+ db.rollback()
+ finally:
+ db.close()
+
+
+if __name__ == "__main__":
+ create_defaults()
+```
+
+Run it:
+```bash
+python scripts/seed/create_default_content_pages.py
+```
+
+## Testing
+
+### 1. Test Platform Defaults
+
+```bash
+# Create platform default
+curl -X POST http://localhost:8000/api/v1/admin/content-pages/platform \
+ -H "Authorization: Bearer " \
+ -H "Content-Type: application/json" \
+ -d '{
+ "slug": "about",
+ "title": "About Our Marketplace",
+ "content": "About Platform default content
",
+ "is_published": true,
+ "show_in_footer": true
+ }'
+
+# View in storefront
+curl http://localhost:8000/store/orion/about
+```
+
+### 2. Test Store Override
+
+```bash
+# Create store override
+curl -X POST http://localhost:8000/api/v1/store/orion/content-pages/ \
+ -H "Authorization: Bearer " \
+ -H "Content-Type: application/json" \
+ -d '{
+ "slug": "about",
+ "title": "About Orion",
+ "content": "About Orion Custom store content
",
+ "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 0000000000000000000000000000000000000000..7e784353525ab5dba9ef66957bcf2d9c3b7e828b
GIT binary patch
literal 19288
zcmdU1OLH4ncAoMkRj5==7FlGG#Vsmjg9yNfWQ}De%#=imHZ^)MB;{EsE})6N0NVn(
z;qC?{j^$le$tE?s?6S%tyZnXGAItZhbMNho2W7gfnyP4Fqr30(-1GR(&_i>o?J|cy2UuQL1Q
z+xDHp{oLgHhvo1xtL?C!mlofW=uy6()kTTb%erI}bk1!^meCzaQ2NES({c{hU;)$tNDyvQ|U2!|sI>X;vTGja<4IQG$5*zTCON6m>5%+m5CA9OhzX*g*PE=qG*z9{^UBmE
z8sW9;P!BH`+meMRNr_oyr5UIzO}{FNF?jL}96Td)A7KyN@ieJ1gIm9}JhjQBS7%de
zrbV8C#dHw~R%Oc)!W(I>n>o0%xOaA>;>l3r)-%%-YnKsf?t!c$qpS1q=m(2gGzvBW_fKR|6
z$t0QwUxY5^%uYtVGb#sYDTCK1PMdIkJZ_7iZ~rhXQv2g}7=d3znzv)dX8&j5;H3Y;
z=g?hdik_OE&A@mkBQ)O@f1%~te|WE2fpq;SG+a9k^pts;T-$Q(*YNVAEMOa2aQkZ4
zokcS?4?(}^S%fdmkL5$y?lj+cT+#V>#r$1yQ8mpz$_Ao2^=G7RphYRVR19nXeUt@I
zA-Rd5MYwTiPo5MPFw{9zrM`YRuZv!3MEf^hl2Iplq2K_soJ@c}{Ib=}%SyID*
zsvw)IEHN*STX)%qt2!vHKq>Zcx_ii1=rLHgjREY&uqe~kJ&v%J_c#=pBrV?LlOjoJ
zQW{%`CqSBu1SStQ?Q@Re$r!ICp4@%+Q&&b;m~PK!DOQ@c?sJqgwX84Y9jx@8;r^Vx
zbuBqHcBh81^&9O*Sat#c14Ax05`-WktISMaHV9HtVpf_V7;aLG8$+nJ!5sn-s$jTQ
ztJ8UT`jl%Kc40+q
zz}f2nu&=Ua1rk9yC@PY^5ihZm=!VMH+{K1Rfg95D5n02WWq%Mlc=QQu?s5DKixmDb
z?cJT7-u<1O9Y7M<;y=%`;cIi0O(q!X1gP4BRqVoT*ZBcLM1vI&u@YOo$kBSj?z3Vx
zhvlY!K^SHeEXRXDG?b$jCSDw4uVqZn4B&@|gV7&WSN(Z8>5t064PR`2)0fn$n1@6A
zI^$&5+*LBpa&o-^!`AdpWEr0Y3I7f@$Fy_%#^UoRE32BYw1I=G2o&-)2Afo7YU?r^
zqPHpwn|6&8oUVdK@gEMIF90c+3>~FNL5f>y>m-|Cq#^}KBH~rD@S8siC2c!m{%)Xe
z*Hp#41bza~Z3@}2IB(PZ@~BOSY_XH+tddrp&SL?!bj(!!eS=?aIaE37b_w#W6p5Dw
zzC(_67Nx2`bA{fDlSyVTLc+GEYSM(LV%(rAEIh$$v%3S1!ZuS(acOfP={mczMoz@O
z#W?80A%NDi;i76H0iPFUMn|KxRGawU_w%fTdJ~2Jvce}(_Ro{6Yzz*d?7&HB%#o9D
zAak{eqw2=4lUBeTV_H;(|@lgLa}&7sW)Tg2)px5H#v1Xh^Ie
z$*+}%oN8^134Po%T1dwt&JG-w#f&DOY*~qch{j+a&w9b$04&a-CEz7Ob}%K_efYXJ
zg7(%}jbq_PM+cbWIEOp|SNKh3ihOdda^|)JnZLL;!+8lts*PZJGlxBen0bzWD1I)-
zc$(#}xom0=lVExm4{-Ew6ab(y2T4VH+VjQ~TJEszM6d%oV
zF`CIFtEtIpaf;CheuI#~Pu4SfPP+R9a31U{!XPFN#_jeJqP|STu0J6m+Wz(uBJCF?
zL|;Wyu1Lro18o$1EffDsj&TAxOX(4ppOrYc{``Eq9a&h
zD~HryZt6kxFH
zF36=)sasUwqgw7bg#yk-VC=GhnfK5~94zv9QiJbu?(!kP#5ItG8v`(3NKMrTl4)G=
z63!cPyN4AV3`3id3+7CLZVMtN_+PUj!0n*Yzd1SQHvS8$1Tjp(iiS&ds*2
zs`aQyulLLkfXT*eC_1NKv$1D3F6(+$?QL({w*cZ3y9GV8?JeRK{GW}k_KKFmZo%Jk
zp9NL+i7N3GF5OP+$JVK>5J0XhW;PoTyndGQ-9abaM$!)EZHe*Hd_A~uElKF@2e=l+h{gY%QZm%I1f2hn8BYxlOM
zspvZeZ)Iy=e1UCa7f&I33POmn;Ma-1jyLmLLel-+Pbb+ltM_+zVq&>H0VG#g->a>y
zt#9Kg@h&Wxqi_%IyNt$Xl3(IXoeuO0{1}+JU_VuLJ^B4P@DfR39b*bvxrTM+2iQytyo*nZ3)&YlP|G1Nv^Es&+q>Q$2+%MS$-X>8!!URmurY8
z6`8&sSCsPFRuGjyI+@c`|~&vqOL4Y!R6>`
zb#!JvSF7WR;0PCc$@4k@ob5Q-i50*M8s2j+!?N-2^9R1O!Vhx&-4AiSvgxh4euZNf
zxZe8?C#qj0|EkIz&fT%k4@=yZC_`kC$fZehsgSprH}NXnv{&6>r@;w$R=vYmh!|;s
za&HM-vT?Rum-7Ui)7Ik;h|c#pSBN8~%4I#zlj^l;yHi+jmQ@ga*Heq|WKR7C2BZHq
zr-K$k-0%|C#+Q{Mhc3FT2~wu^8%cHB0c--YhkuD^DZ-alP3Bpr$ngQk&d6!`e-~3rJbKnobGna)mAIR1E
zex3!O1H}j;lGIM^C_&*4pO3#c*(FDkBt94NlL%~&|8ubE~e!_
zJ||qAFkNya9{GflZ*)R(OR${pU2VH7iK874|43QZ)?(GIp>{mj0
zI)LxEXJTvHKnEq$k!5_EAt~5t>Qr5|Ba7S3%VQU0^vQ+*-F3-6SmeXo>QG38M=k7`
zk9wfEMe8r3YKaiarcWu67$3++lqg1a%n~uFk2tWMAS&5LT-HviZSn};tImLS+H9?f
zTkm0Tr1I{#*uIaTuQs)LXzD5=Zf=_b(z#(ZJrJ6D%n`yeYjY-krvs`rAN3L<0OYB)
z(&UOwvs%%C%mCqm?_tig#6!zZT$m^EAz7ULHBBV61}aVvWsV9$#PxsdR0-Xcm*+>l
z2l)R})h4EWeD=a=69(LWpXG>#RXu7I(>^#+?V^srW8+>Ry!S-yK6MwurEupquA#P`igw$Gcx{efedVs2`vo7^3@#9ijc`p5A)Zj|%vgBViWs
z0D_$BxC5>@*<*@$v`tYg=EXV4+cS|fwdzsWBA8Q&Y%}aFj$7m(_0>f7iO0ab3-Mdg
zY~H=4=9y$8JG>skwUyOq_9oTr^MJdJJM23rSl3GKSlO#@xzh>*nD(zpU3$v
zLmN{f=hshw3!uC$(_hjuz}hvRG&HsP!LN5=FgNe1btJybB(=t=;S;%>xNa8=q2&eg
zsG&Lr(X0B^3a>wATJBpOCLoo>`a<~`%Lpp3Dj1+qSy-}A5l2O`u>h!XQcYw~Ib5dw
zNg>mvFN@ikSX7t+U2YWdGsy)qLy(m;LscSk7s?hcIVuijX_-a1g%HXTZxTZtms%he
zy`$d|C9iDuNpT@30a)12=hF)w59FCdKSh$0AVHI=zzxz6;cdp`GB$L!*z5e`Lx6;a
znha48^kEfULv@Qpa6Q~onG^E$>+4x#(x-^J0c!VEnH&Ge-bi&-Rs*{_NW(pFA1kIe
z!)m^sBPoK~p(K?O+{h$^jS|8@7BT>Q?DNUli|3}wlNn;Kk^*>buRZ9Ix?kc*`T(F&
zOU3|gR#?5E`rdjfJBT7jXU?&4#<*d%2Y#cnZ)OTf_{*d+IfB46z`B^YX_hXvbEG!M
zhin#+i|uGxgoNCLf?~G10<;Gupc9p{p=tBMw}vhGXhP<=C4UQ%hPA?hf1(Phk)&}UOLAEP)%`NCAaf&!P*q9K76n2POL_8lD@@1ZV
zKS%ZUp=Ktd*3w(8CIkA?!*iHh6&!a36gVc+UJHs8pQ~62zvwu$JQ4FZFA3Y4rv@A~
z?*usQLF*3jbt0?OZ0HctTnarbGjNf5kg+$|48ckUgdqw=_cDYauciexLi048xd&;=
z;GTAep7j35=R1FI5b-ZF)EQXk*dhRp+!8ZcEE}`F<<2!-BWoHzc4cghjCqkljD{6%!yYaoKq%Kpn49k=vC_@QU@;<3M8aT>Rt#9!
z5RZ+S2rSt!ms`R{v9!l%z#=d*TOA~MXcHfrHkD3QGO<_yLLH%}ypIo~VcaD~ckHW+
z@&2Z~%&PuKi|T-fFIYjbZ;p~lrC1DKwc;myJ7-Lkn{a8qJ|K^?&Yq`_^D7u*)^@QS
zqT&7T=LxKH4Kekge44yndh3?BYQp45VzjpAL>7#jKLIBmPSGLg03DHF0RqbWLEk7N
z~wz1N>UGIUs%2q_C9N_cf2T
zX?p);4uMf#ftrXn=JmHOaJQj~H$*Y%z`4apxMBo6tSq8YQq=#DKlo2marzrkK5s!8
z#P+$J@X={PeDevZ@JY}clSFrQ3*mzgvF{=y}Ma38TiJTpWW=G$8>4%o@e`bZzLA>X#u*Kq`%BYRrsvp1g2Dyu@MTmW{V`E|{ZB
zc_f9Uj>u?=@f64*p6lvagC`#fFM+K+tFAC4{2-R+F4U7nK1$zxUuDu~jnz4pct2)w
z@^A~DRkIgOq@25kD`VH)vF@VxtuL3koY0^_N5aGFRvb9l1&x~oGQ**8Y~ek~
zF2$WevM5X2Xy6?i#MG(v{o_wh=hM7$(u|W~ifhe$#(jC-;jxXW)>l8g-)MP&MDw~Suvw&ICZFSJL
zJ%~*gikdi0I6Rn5T;iY>Me_BV+123w#m}nXdDp|6n7(|
zB)2V)R{$7Cr46J`ZXz-bkfMIl#V}a1dnz|P{C4QHglg-cAQ2pvDr9~=L+V$qy{Z*I
z6A-8Og=eI(E=~
z))a*O(|`B}kgXXA_io^Yd8DOt{n4BOC04wXWB&A?zlNH3mjT4YvC>b|zRNq&w?DBH
zRIo$+SG;$KjsA~rUmy41|MBLPOK-NkBBc7
zXDNrM(UU^EvwDsz7YI>poM_=AtOkPK1;R*(j)U1@!(5PMS)_ABddhj8t0^aTE|Q`vI%vQQX9MZagKI$l}CoghDKfJ5xc@;O}5-
z^7$dAZq}2=p>{`6>vs2V)>W{5l{pyoWq6dZchu5*{3RJ#n}MKO~SEl;H#g)?jib
z!MZ*N)@4pEPJC@rVL8%;sn_Mw8Q*6*Jy256X$4C`|GE@Pc0uf=qMxnLC&Ihc7yOATGX*f7kiTOAX&J!FJ@gSqQ+&5FS
zs2q_J1+7=ma5Dm+bJaUdp~LZ^CO?;?SuXypWgoeeBy`lMsk>aSvtnl6NHq2>YWR7*
z3YgDXwLnWAjK7uRwgNbZ*tUf1Tx|sf{4pdEfeh|IlZYy5?cIBb1dOv9IyK}BAF+<$
z1Vsx7cDj01bA*Y6!LUQQ-q{oZI1L1yNU8AA3E>&JKjHKex5>*h7+b{zLBNSGGT|i?
z$_gYIaG>#BQA>!cbl5Jg73tk`Z-T%Ca(f3oao$jCPD6GYK`OZTzfZ`Dn^fqB<@2a7NN!jE1r`iW0yW2ETv-3LmQUCr76Q=N&8R6=tC
zsmi%UU(|tQ>5m-2dx{R~{+uz`z45!=DdQ>f`?C-xVPWizMX$@$5#A8ky=IRe9BPGD
z-sJw=fdauiY+QF_A6^QNsq*L6@ID3#%2EQJP|k7$1S-?q25aQiUFg_-N^5^&Lvr)+WD
zOs9XBNZO+dfhDl5mSFbQuPSU1V5y={Yp%x4ykl@r+Enti14^#
zBPLYeAf3%(2rAb2nzM9rTLr8=e0+8=fMvE9$Tb36ivmTYpZDoN89-Hp;?;K$3hx98
z@9g&O?U2Iq#}km1YQlBinyDg|8I?iSm>;(u7P#N^HQYvPF0_CK{!SSwxb!!Od~Ip_
m=oEi_=y?~rWYVw~$9_Ot#GuFb+#M;
literal 0
HcmV?d00001
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 ID: #
+
Type:
+
Status:
+
Store:
+
+
+
+
+
Started:
+
Completed:
+
Duration:
+
+
+
+
+
+
Export Details
+
Products exported:
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+#### 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 = """
+
+"""
+
+def _inject_powered_by_footer(self, body_html, store_id):
+ tier = self._get_store_tier(store_id)
+ if tier and tier.lower() in WHITELABEL_TIERS:
+ return body_html # No footer for business/enterprise
+ return body_html.replace("
+
+
+
+
+
De:
+
{{ seller.merchant_name }}
+ {{ seller.address }}
+ {{ seller.postal_code }} {{ seller.city }}
+ {{ seller.country }}
+ {% if seller.vat_number %}TVA: {{ seller.vat_number }}{% endif %}
+
+
+
Facturé à:
+
{{ buyer.name }}
+ {% if buyer.merchant %}{{ buyer.merchant }}
{% endif %}
+ {{ buyer.address }}
+ {{ buyer.postal_code }} {{ buyer.city }}
+ {{ buyer.country }}
+ {% if buyer.vat_number %}TVA: {{ buyer.vat_number }}{% endif %}
+
+
+
+ {% if invoice.order_id %}
+ Référence commande: {{ invoice.order_id }}
+ {% endif %}
+
+
+
+
+ Description
+ Qté
+ Prix unit. HT
+ Total HT
+
+
+
+ {% for item in items %}
+
+ {{ item.description }}{% if item.sku %} ({{ item.sku }}) {% endif %}
+ {{ item.quantity }}
+ €{{ "%.2f"|format(item.unit_price_net) }}
+ €{{ "%.2f"|format(item.line_total_net) }}
+
+ {% endfor %}
+
+
+
+
+
+ Sous-total HT:
+ €{{ "%.2f"|format(invoice.subtotal_net) }}
+
+
+ {{ vat_label }} ({{ invoice.vat_rate }}%):
+ €{{ "%.2f"|format(invoice.vat_amount) }}
+
+
+ TOTAL TTC:
+ €{{ "%.2f"|format(invoice.total_gross) }}
+
+
+
+ {% 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() %}
+
+{% 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="
",
+ body_text="Hello\n\nCustom message",
+)
+```
+
+## Email Templates
+
+### Creating Templates
+
+Templates use Jinja2 syntax for variable interpolation:
+
+```html
+
+```
+
+### Seeding Templates
+
+Run the seed script to populate default templates:
+
+```bash
+python scripts/seed/seed_email_templates.py
+```
+
+This creates templates for:
+
+- `signup_welcome` (en, fr, de, lb)
+
+### Available Variables
+
+For `signup_welcome`:
+
+| Variable | Description |
+|----------|-------------|
+| first_name | User's first name |
+| merchant_name | Store merchant name |
+| email | User's email address |
+| store_code | Store code for dashboard URL |
+| login_url | Direct link to dashboard |
+| trial_days | Number of trial days |
+| tier_name | Subscription tier name |
+
+## Provider Setup
+
+### SMTP
+
+Standard SMTP configuration:
+
+```env
+EMAIL_PROVIDER=smtp
+SMTP_HOST=smtp.gmail.com
+SMTP_PORT=587
+SMTP_USER=your-email@gmail.com
+SMTP_PASSWORD=your-app-password
+SMTP_USE_TLS=true
+```
+
+### SendGrid
+
+1. Create account at [sendgrid.com](https://sendgrid.com)
+2. Generate API key in Settings > API Keys
+3. Configure:
+
+```env
+EMAIL_PROVIDER=sendgrid
+SENDGRID_API_KEY=SG.xxxxxxxxxxxxxxxxxxxx
+```
+
+4. Install package: `pip install sendgrid`
+
+### Mailgun
+
+1. Create account at [mailgun.com](https://mailgun.com)
+2. Add and verify your domain
+3. Get API key from Domain Settings
+4. Configure:
+
+```env
+EMAIL_PROVIDER=mailgun
+MAILGUN_API_KEY=key-xxxxxxxxxxxxxxxx
+MAILGUN_DOMAIN=mg.yourdomain.com
+```
+
+### Amazon SES
+
+1. Set up SES in AWS Console
+2. Verify sender domain/email
+3. Create IAM user with SES permissions
+4. Configure:
+
+```env
+EMAIL_PROVIDER=ses
+AWS_ACCESS_KEY_ID=AKIAXXXXXXXXXXXXXXXX
+AWS_SECRET_ACCESS_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+AWS_REGION=eu-west-1
+```
+
+5. Install package: `pip install boto3`
+
+## Email Logging
+
+All emails are logged to the `email_logs` table. Query examples:
+
+```python
+# Get failed emails
+failed = db.query(EmailLog).filter(
+ EmailLog.status == EmailStatus.FAILED.value
+).all()
+
+# Get emails for a store
+store_emails = db.query(EmailLog).filter(
+ EmailLog.store_id == store_id
+).order_by(EmailLog.created_at.desc()).all()
+
+# Get recent signup emails
+signups = db.query(EmailLog).filter(
+ EmailLog.template_code == "signup_welcome",
+ EmailLog.created_at >= datetime.now() - timedelta(days=7)
+).all()
+```
+
+## Language Fallback
+
+The system automatically falls back to English if a template isn't available in the requested language:
+
+1. Request template for "de" (German)
+2. If not found, try "en" (English)
+3. If still not found, return None (log error)
+
+## Testing
+
+Run email service tests:
+
+```bash
+pytest tests/unit/services/test_email_service.py -v
+```
+
+Test coverage includes:
+
+- Provider abstraction (Debug, SMTP, etc.)
+- Template rendering with Jinja2
+- Language fallback behavior
+- Email sending success/failure
+- EmailLog model methods
+- Template variable handling
+
+## Architecture
+
+```
+app/services/email_service.py # Email service with provider abstraction
+models/database/email.py # EmailTemplate and EmailLog models
+app/core/config.py # Email configuration settings
+scripts/seed/seed_email_templates.py # Template seeding script
+```
+
+### Provider Abstraction
+
+The system uses a strategy pattern for email providers:
+
+```
+EmailProvider (ABC)
+├── SMTPProvider
+├── SendGridProvider
+├── MailgunProvider
+├── SESProvider
+└── DebugProvider
+```
+
+Each provider implements the `send()` method with the same signature, making it easy to switch providers via configuration.
+
+## Future Enhancements
+
+Planned improvements:
+
+1. **Email Queue**: Background task queue for high-volume sending
+2. **Webhook Tracking**: Track deliveries, opens, clicks via provider webhooks
+3. **Template Editor**: Admin UI for editing templates
+4. **A/B Testing**: Test different email versions
+5. **Scheduled Emails**: Send emails at specific times
diff --git a/app/modules/messaging/docs/index.md b/app/modules/messaging/docs/index.md
new file mode 100644
index 00000000..941cb531
--- /dev/null
+++ b/app/modules/messaging/docs/index.md
@@ -0,0 +1,63 @@
+# Messaging & Notifications
+
+Core email and notification system for user registration, password resets, team invitations, and system notifications. Required for basic platform operations.
+
+## Overview
+
+| Aspect | Detail |
+|--------|--------|
+| Code | `messaging` |
+| Classification | Core |
+| Dependencies | None |
+| Status | Active |
+
+## Features
+
+- `customer_messaging` — Customer-facing email communications
+- `internal_messages` — Internal messaging system
+- `notification_center` — Admin notification center
+- `message_attachments` — File attachments for messages
+- `admin_notifications` — System notifications for admins
+
+## Permissions
+
+| Permission | Description |
+|------------|-------------|
+| `messaging.view_messages` | View messages |
+| `messaging.send_messages` | Send messages |
+| `messaging.manage_templates` | Manage email templates |
+
+## Data Model
+
+See [Data Model](data-model.md) for full entity relationships and schema.
+
+- **EmailTemplate** — Multi-language email templates with Jinja2 variables
+- **StoreEmailTemplate** — Per-store template overrides
+- **EmailLog** — Email sending history and delivery tracking
+- **StoreEmailSettings** — Per-store email provider configuration
+- **AdminNotification** — System alerts and admin notifications
+- **Conversation** — Threaded conversations with polymorphic participants
+- **ConversationParticipant** — Participant links (admin, store, customer)
+- **Message** — Individual messages with soft delete
+- **MessageAttachment** — File attachments for messages
+
+## API Endpoints
+
+| Method | Path | Description |
+|--------|------|-------------|
+| `*` | `/api/v1/admin/messages/*` | Message management |
+| `*` | `/api/v1/admin/notifications/*` | Notification management |
+| `*` | `/api/v1/admin/email-templates/*` | Email template CRUD |
+
+## Configuration
+
+Email provider settings are configured at the store level via the settings UI.
+
+## Additional Documentation
+
+- [Data Model](data-model.md) — Entity relationships and database schema
+- [Architecture](architecture.md) — Messaging system architecture
+- [Notifications](notifications.md) — Admin notification system
+- [Email System](email-system.md) — Email delivery system overview
+- [Email Settings](email-settings.md) — Provider configuration guide
+- [Email Settings Implementation](email-settings-impl.md) — Email settings technical details
diff --git a/app/modules/messaging/docs/notifications.md b/app/modules/messaging/docs/notifications.md
new file mode 100644
index 00000000..89a45839
--- /dev/null
+++ b/app/modules/messaging/docs/notifications.md
@@ -0,0 +1,187 @@
+# Admin Notification System
+
+## Overview
+
+The admin notification system provides real-time alerts and notifications to platform administrators for important events, errors, and system status updates.
+
+## Components
+
+### Backend
+
+#### Database Models
+
+Located in `models/database/admin.py`:
+
+- **AdminNotification**: Stores individual notifications
+ - `type`: Notification type (import_failure, order_sync_failure, etc.)
+ - `priority`: low, normal, high, critical
+ - `title`, `message`: Content
+ - `is_read`, `read_at`, `read_by_user_id`: Read tracking
+ - `action_required`, `action_url`: Optional action link
+ - `notification_metadata`: JSON for additional context
+
+- **PlatformAlert**: Stores platform-wide alerts
+ - `alert_type`: security, performance, capacity, integration, etc.
+ - `severity`: info, warning, error, critical
+ - `affected_stores`, `affected_systems`: Scope tracking
+ - `occurrence_count`, `first_occurred_at`, `last_occurred_at`: Deduplication
+ - `is_resolved`, `resolved_at`, `resolution_notes`: Resolution tracking
+
+#### Service Layer
+
+Located in `app/services/admin_notification_service.py`:
+
+```python
+from app.services.admin_notification_service import (
+ admin_notification_service,
+ platform_alert_service,
+ NotificationType,
+ Priority,
+ AlertType,
+ Severity,
+)
+```
+
+**AdminNotificationService** methods:
+
+| Method | Description |
+|--------|-------------|
+| `create_notification()` | Create a new notification |
+| `get_notifications()` | List notifications with filters |
+| `get_recent_notifications()` | Get recent unread for header dropdown |
+| `get_unread_count()` | Count unread notifications |
+| `mark_as_read()` | Mark single notification read |
+| `mark_all_as_read()` | Mark all as read |
+| `delete_notification()` | Delete a notification |
+
+**Convenience methods** for common scenarios:
+
+| Method | Use Case |
+|--------|----------|
+| `notify_import_failure()` | Product/order import failed |
+| `notify_order_sync_failure()` | Letzshop sync failed |
+| `notify_order_exception()` | Order has unmatched products |
+| `notify_critical_error()` | System critical error |
+| `notify_store_issue()` | Store-related problem |
+| `notify_security_alert()` | Security event detected |
+
+**PlatformAlertService** methods:
+
+| Method | Description |
+|--------|-------------|
+| `create_alert()` | Create a new platform alert |
+| `get_alerts()` | List alerts with filters |
+| `resolve_alert()` | Mark alert as resolved |
+| `get_statistics()` | Get alert counts and stats |
+| `create_or_increment_alert()` | Deduplicate recurring alerts |
+
+#### API Endpoints
+
+Located in `app/api/v1/admin/notifications.py`:
+
+**Notifications:**
+- `GET /api/v1/admin/notifications` - List with filters
+- `POST /api/v1/admin/notifications` - Create (manual)
+- `GET /api/v1/admin/notifications/recent` - For header dropdown
+- `GET /api/v1/admin/notifications/unread-count` - Badge count
+- `PUT /api/v1/admin/notifications/{id}/read` - Mark read
+- `PUT /api/v1/admin/notifications/mark-all-read` - Mark all read
+- `DELETE /api/v1/admin/notifications/{id}` - Delete
+
+**Platform Alerts:**
+- `GET /api/v1/admin/notifications/alerts` - List with filters
+- `POST /api/v1/admin/notifications/alerts` - Create (manual)
+- `PUT /api/v1/admin/notifications/alerts/{id}/resolve` - Resolve
+- `GET /api/v1/admin/notifications/alerts/stats` - Statistics
+
+### Frontend
+
+#### Header Dropdown
+
+Located in `app/templates/admin/partials/header.html`:
+
+- Real-time notification bell with unread count badge
+- Polls for new notifications every 60 seconds
+- Quick actions: mark as read, view all
+- Priority-based color coding
+
+#### Notifications Page
+
+Located in `app/templates/admin/notifications.html` with `static/admin/js/notifications.js`:
+
+- Full notifications management interface
+- Two tabs: Notifications and Platform Alerts
+- Statistics cards (unread, active alerts, critical, resolved today)
+- Filtering by priority, type, read status
+- Bulk operations (mark all read)
+- Alert resolution workflow
+
+## Automatic Triggers
+
+Notifications are automatically created in these scenarios:
+
+### Import Failures
+
+**Product Import** (`app/tasks/background_tasks.py`):
+- When a product import job fails completely
+- When import completes with 5+ errors
+
+**Historical Order Import** (`app/tasks/letzshop_tasks.py`):
+- When Letzshop API returns an error
+- When import fails with an unexpected exception
+
+### Example Usage
+
+```python
+from app.services.admin_notification_service import admin_notification_service
+
+# In a background task or service
+admin_notification_service.notify_import_failure(
+ db=db,
+ store_name="Acme Corp",
+ job_id=123,
+ error_message="CSV parsing failed: invalid column format",
+ store_id=5,
+)
+db.commit()
+```
+
+## Priority Levels
+
+| Priority | When to Use | Badge Color |
+|----------|-------------|-------------|
+| `critical` | System down, data loss risk | Red |
+| `high` | Import/sync failures, action needed | Orange |
+| `normal` | Informational alerts | Blue |
+| `low` | Minor issues, suggestions | Gray |
+
+## Architecture
+
+```
+┌─────────────────┐ ┌──────────────────────┐
+│ Background │────▶│ Notification │
+│ Tasks │ │ Service │
+└─────────────────┘ └──────────┬───────────┘
+ │
+┌─────────────────┐ ▼
+│ API Endpoints │◀───────────────┤
+└─────────────────┘ │
+ ▼
+┌─────────────────┐ ┌──────────────────────┐
+│ Header │◀────│ Database │
+│ Dropdown │ │ (admin_notifications│
+└─────────────────┘ │ platform_alerts) │
+ │ └──────────────────────┘
+ ▼
+┌─────────────────┐
+│ Notifications │
+│ Page │
+└─────────────────┘
+```
+
+## Future Enhancements
+
+- Email notifications for critical alerts
+- Webhook integration for external systems
+- Customizable notification preferences per admin
+- Scheduled notification digests
diff --git a/app/modules/monitoring/docs/index.md b/app/modules/monitoring/docs/index.md
new file mode 100644
index 00000000..220f5535
--- /dev/null
+++ b/app/modules/monitoring/docs/index.md
@@ -0,0 +1,44 @@
+# Platform Monitoring
+
+Logs, background tasks, imports, system health, Flower, and Grafana integration.
+
+## Overview
+
+| Aspect | Detail |
+|--------|--------|
+| Code | `monitoring` |
+| Classification | Internal |
+| Dependencies | None |
+| Status | Active |
+
+## Features
+
+- `application_logs` — Application log viewer
+- `background_tasks` — Celery task monitoring
+- `import_jobs` — Import job tracking
+- `capacity_monitoring` — System capacity metrics
+- `testing_hub` — Test execution monitoring
+- `code_quality` — Code quality dashboard
+- `flower_integration` — Flower (Celery monitoring) integration
+- `grafana_integration` — Grafana dashboard integration
+
+## Permissions
+
+No permissions defined (internal module, admin-only access).
+
+## Data Model
+
+- **CapacitySnapshot** — System capacity metric snapshots
+
+## API Endpoints
+
+| Method | Path | Description |
+|--------|------|-------------|
+| `*` | `/api/v1/admin/logs/*` | Log viewer |
+| `*` | `/api/v1/admin/platform-health/*` | Platform health metrics |
+| `*` | `/api/v1/admin/audit/*` | Audit log viewer |
+| `*` | `/api/v1/admin/code-quality/*` | Code quality dashboard |
+
+## Configuration
+
+No module-specific configuration.
diff --git a/app/modules/orders/docs/architecture.md b/app/modules/orders/docs/architecture.md
new file mode 100644
index 00000000..3de44980
--- /dev/null
+++ b/app/modules/orders/docs/architecture.md
@@ -0,0 +1,345 @@
+# Customer-Orders Architecture
+
+This document describes the consumer-agnostic customer architecture, following the same pattern as [Media Architecture](../../architecture/media-architecture.md).
+
+## Overview
+
+```
+┌─────────────────────────────────────────────────────────────────────┐
+│ CONSUMER MODULES │
+│ │
+│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
+│ │ Orders │ │ Loyalty │ │ Future │ │
+│ │ │ │ (future) │ │ Module │ │
+│ │ Order model │ │LoyaltyPoints│ │ XxxCustomer │ │
+│ │ (customer_id)│ │(customer_id)│ │ │ │
+│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
+│ │ │ │ │
+│ └──────────────────┼──────────────────┘ │
+│ │ │
+│ ▼ │
+│ ┌─────────────────────────────────────────────────────────────┐ │
+│ │ Customers Module │ │
+│ │ │ │
+│ │ Customer (generic, consumer-agnostic storage) │ │
+│ │ CustomerService (CRUD, authentication, profile management) │ │
+│ └─────────────────────────────────────────────────────────────┘ │
+└─────────────────────────────────────────────────────────────────────┘
+```
+
+## Design Principles
+
+### 1. Consumer-Agnostic Customer Storage
+
+The customers module provides **generic customer storage** without knowing what entities will reference customers:
+
+- `Customer` stores customer data (email, name, addresses, preferences)
+- `CustomerService` handles CRUD, authentication, and profile management
+- Customers module has **no knowledge** of orders, loyalty points, or any specific consumers
+
+### 2. Consumer-Owned Relationships
+
+Each module that references customers defines its **own relationship**:
+
+- **Orders**: `Order.customer_id` links orders to customers
+- **Future Loyalty**: Would define `LoyaltyPoints.customer_id`
+- **Future Subscriptions**: Would define `Subscription.customer_id`
+
+This follows the principle: **The consumer knows what it needs, the provider doesn't need to know who uses it.**
+
+### 3. Correct Dependency Direction
+
+```
+WRONG (Hidden Dependency):
+ Customers → Orders (customers imports Order model)
+
+CORRECT:
+ Orders → Customers (orders references Customer via FK)
+```
+
+Optional modules (orders) depend on core modules (customers), never the reverse.
+
+## Key Components
+
+### Customer Model (Customers Module)
+
+```python
+# app/modules/customers/models/customer.py
+
+class Customer(Base, TimestampMixin):
+ """Generic customer - consumer-agnostic."""
+
+ __tablename__ = "customers"
+
+ id = Column(Integer, primary_key=True)
+ store_id = Column(Integer, ForeignKey("stores.id"), nullable=False)
+
+ # Authentication
+ email = Column(String(255), nullable=False)
+ hashed_password = Column(String(255))
+
+ # Profile
+ first_name = Column(String(100))
+ last_name = Column(String(100))
+ phone = Column(String(50))
+ customer_number = Column(String(50), unique=True)
+
+ # Preferences
+ marketing_consent = Column(Boolean, default=False)
+ preferred_language = Column(String(10))
+
+ # Status
+ is_active = Column(Boolean, default=True)
+
+ # Note: Consumer-specific relationships (orders, loyalty points, etc.)
+ # are defined in their respective modules. Customers module doesn't
+ # know about specific consumers.
+```
+
+### CustomerService (Customers Module)
+
+The `CustomerService` provides generic operations:
+
+```python
+# app/modules/customers/services/customer_service.py
+
+class CustomerService:
+ """Generic customer operations - consumer-agnostic."""
+
+ def create_customer(self, db, store_id, customer_data):
+ """Create a new customer."""
+ ...
+
+ def get_customer(self, db, store_id, customer_id):
+ """Get a customer by ID."""
+ ...
+
+ def update_customer(self, db, store_id, customer_id, customer_data):
+ """Update customer profile."""
+ ...
+
+ def login_customer(self, db, store_id, email, password):
+ """Authenticate a customer."""
+ ...
+
+ # Note: Customer order methods have been moved to the orders module.
+ # Use orders.services.customer_order_service for order-related operations.
+```
+
+### Order Model (Orders Module)
+
+```python
+# app/modules/orders/models/order.py
+
+class Order(Base, TimestampMixin):
+ """Order with customer reference - orders owns the relationship."""
+
+ __tablename__ = "orders"
+
+ id = Column(Integer, primary_key=True)
+ store_id = Column(Integer, ForeignKey("stores.id"), nullable=False)
+
+ # Customer reference - orders module owns this relationship
+ customer_id = Column(Integer, ForeignKey("customers.id"))
+
+ # Order data
+ order_number = Column(String(50), unique=True)
+ status = Column(String(20), default="pending")
+ total_cents = Column(Integer)
+ ...
+
+ # Relationship to customer
+ customer = relationship("Customer", lazy="joined")
+```
+
+### CustomerOrderService (Orders Module)
+
+```python
+# app/modules/orders/services/customer_order_service.py
+
+class CustomerOrderService:
+ """Customer-order operations - owned by orders module."""
+
+ def get_customer_orders(self, db, store_id, customer_id, skip=0, limit=50):
+ """Get orders for a specific customer."""
+ ...
+
+ def get_recent_orders(self, db, store_id, customer_id, limit=5):
+ """Get recent orders for a customer."""
+ ...
+
+ def get_order_count(self, db, store_id, customer_id):
+ """Get total order count for a customer."""
+ ...
+```
+
+### Customer Order Metrics (Orders Module)
+
+Order statistics for customers use the MetricsProvider pattern:
+
+```python
+# app/modules/orders/services/order_metrics.py
+
+class OrderMetricsProvider:
+ """Metrics provider including customer-level order metrics."""
+
+ def get_customer_order_metrics(self, db, store_id, customer_id, context=None):
+ """
+ Get order metrics for a specific customer.
+
+ Returns MetricValue objects for:
+ - total_orders: Total orders placed
+ - total_spent: Total amount spent
+ - avg_order_value: Average order value
+ - last_order_date: Date of most recent order
+ - first_order_date: Date of first order
+ """
+ ...
+```
+
+## API Endpoints
+
+### Customers Module Endpoints
+
+Customer CRUD operations (no order data):
+
+```
+GET /api/store/customers → List customers
+GET /api/store/customers/{id} → Customer details (no order stats)
+PUT /api/store/customers/{id} → Update customer
+PUT /api/store/customers/{id}/status → Toggle active status
+```
+
+### Orders Module Endpoints
+
+Customer order data (owned by orders):
+
+```
+GET /api/store/customers/{id}/orders → Customer's order history
+GET /api/store/customers/{id}/order-stats → Customer's order statistics
+```
+
+## Adding Customer References to a New Module
+
+When creating a module that references customers (e.g., a loyalty module):
+
+### Step 1: Reference Customer via Foreign Key
+
+```python
+# app/modules/loyalty/models/loyalty_points.py
+
+class LoyaltyPoints(Base):
+ """Loyalty points - owned by loyalty module."""
+
+ __tablename__ = "loyalty_points"
+
+ id = Column(Integer, primary_key=True)
+ store_id = Column(Integer, ForeignKey("stores.id"))
+
+ # Reference to customer - loyalty module owns this
+ customer_id = Column(Integer, ForeignKey("customers.id"))
+
+ points_balance = Column(Integer, default=0)
+ tier = Column(String(20), default="bronze")
+
+ # Relationship
+ customer = relationship("Customer", lazy="joined")
+```
+
+### Step 2: Create Your Service
+
+```python
+# app/modules/loyalty/services/customer_loyalty_service.py
+
+class CustomerLoyaltyService:
+ """Customer loyalty operations - owned by loyalty module."""
+
+ def get_customer_points(self, db, store_id, customer_id):
+ """Get loyalty points for a customer."""
+ ...
+
+ def add_points(self, db, store_id, customer_id, points, reason):
+ """Add points to customer's balance."""
+ ...
+```
+
+### Step 3: Add Routes in Your Module
+
+```python
+# app/modules/loyalty/routes/api/store.py
+
+@router.get("/customers/{customer_id}/loyalty")
+def get_customer_loyalty(customer_id: int, ...):
+ """Get loyalty information for a customer."""
+ return loyalty_service.get_customer_points(db, store_id, customer_id)
+```
+
+## Benefits of This Architecture
+
+1. **Module Independence**: Orders can be disabled without affecting customers
+2. **Extensibility**: New modules easily reference customers
+3. **No Hidden Dependencies**: Dependencies flow in one direction
+4. **Clean Separation**: Customers handles identity, consumers handle their domain
+5. **Testability**: Can test customers without any consumer modules
+6. **Single Responsibility**: Each module owns its domain
+
+## Anti-Patterns to Avoid
+
+### Don't: Import Consumer Models in Customers
+
+```python
+# BAD - Creates hidden dependency
+class CustomerService:
+ def get_customer_orders(self, db, customer_id):
+ from app.modules.orders.models import Order # Wrong!
+ return db.query(Order).filter(Order.customer_id == customer_id).all()
+```
+
+### Don't: Add Consumer-Specific Fields to Customer
+
+```python
+# BAD - Customer shouldn't know about orders
+class Customer(Base):
+ # These create coupling to orders module
+ total_orders = Column(Integer) # Wrong approach
+ last_order_date = Column(DateTime) # Wrong approach
+```
+
+Instead, query order data from the orders module when needed.
+
+### Don't: Put Consumer Routes in Customers Module
+
+```python
+# BAD - customers/routes shouldn't serve order data
+@router.get("/customers/{id}/orders")
+def get_customer_orders(customer_id: int):
+ from app.modules.orders.models import Order # Wrong!
+ ...
+```
+
+## Migration Note
+
+Previously, the customers module had methods that imported from orders:
+
+```python
+# OLD (removed)
+class CustomerService:
+ def get_customer_orders(self, db, store_id, customer_id):
+ from app.modules.orders.models import Order # Lazy import
+ ...
+
+ def get_customer_statistics(self, db, store_id, customer_id):
+ from app.modules.orders.models import Order # Lazy import
+ ...
+```
+
+These have been moved to the orders module:
+- `get_customer_orders()` → `orders.services.customer_order_service`
+- `get_customer_statistics()` → `orders.services.order_metrics.get_customer_order_metrics()`
+
+## Related Documentation
+
+- [Media Architecture](../../architecture/media-architecture.md) - Similar pattern for media files
+- [Module System Architecture](../../architecture/module-system.md) - Module structure and dependencies
+- [Cross-Module Import Rules](../../architecture/cross-module-import-rules.md) - Import restrictions
+- [Metrics Provider Pattern](../../architecture/metrics-provider-pattern.md) - Provider pattern for statistics
diff --git a/app/modules/orders/docs/data-model.md b/app/modules/orders/docs/data-model.md
new file mode 100644
index 00000000..a346c87d
--- /dev/null
+++ b/app/modules/orders/docs/data-model.md
@@ -0,0 +1,229 @@
+# Orders Data Model
+
+Entity relationships and database schema for the orders module.
+
+## Entity Relationship Overview
+
+```
+Store 1──* Order 1──* OrderItem *──1 Product
+ │ │
+ │ └──? OrderItemException
+ │
+ └──* Invoice
+
+Store 1──1 StoreInvoiceSettings
+Store 1──* CustomerOrderStats *──1 Customer
+```
+
+## Models
+
+### Order
+
+Unified order model for all sales channels (direct store orders and marketplace orders). Stores customer and address data as snapshots at order time. All monetary amounts are stored as integer cents (e.g., €105.91 = 10591).
+
+| Field | Type | Constraints | Description |
+|-------|------|-------------|-------------|
+| `id` | Integer | PK | Primary key |
+| `store_id` | Integer | FK, not null, indexed | Reference to store |
+| `customer_id` | Integer | FK, not null, indexed | Reference to customer |
+| `order_number` | String(100) | not null, indexed | Unique order identifier |
+| `channel` | String(50) | default "direct", indexed | Order source: "direct" or "letzshop" |
+| `external_order_id` | String(100) | nullable, indexed | Marketplace order ID |
+| `external_shipment_id` | String(100) | nullable, indexed | Marketplace shipment ID |
+| `external_order_number` | String(100) | nullable | Marketplace order number |
+| `external_data` | JSON | nullable | Raw marketplace data for debugging |
+| `status` | String(50) | default "pending", indexed | pending, processing, shipped, delivered, cancelled, refunded |
+| `subtotal_cents` | Integer | nullable | Subtotal in cents |
+| `tax_amount_cents` | Integer | nullable | Tax in cents |
+| `shipping_amount_cents` | Integer | nullable | Shipping cost in cents |
+| `discount_amount_cents` | Integer | nullable | Discount in cents |
+| `total_amount_cents` | Integer | not null | Total in cents |
+| `currency` | String(10) | default "EUR" | Currency code |
+| `vat_regime` | String(20) | nullable | domestic, oss, reverse_charge, origin, exempt |
+| `vat_rate` | Numeric(5,2) | nullable | VAT rate percentage |
+| `vat_rate_label` | String(100) | nullable | Human-readable VAT label |
+| `vat_destination_country` | String(2) | nullable | Destination country ISO code |
+| `customer_first_name` | String(100) | not null | Customer first name snapshot |
+| `customer_last_name` | String(100) | not null | Customer last name snapshot |
+| `customer_email` | String(255) | not null | Customer email snapshot |
+| `customer_phone` | String(50) | nullable | Customer phone |
+| `customer_locale` | String(10) | nullable | Customer locale (en, fr, de, lb) |
+| `ship_*` | Various | not null | Shipping address fields (first_name, last_name, company, address_line_1/2, city, postal_code, country_iso) |
+| `bill_*` | Various | not null | Billing address fields (same structure as shipping) |
+| `shipping_method` | String(100) | nullable | Shipping method |
+| `tracking_number` | String(100) | nullable | Tracking number |
+| `tracking_provider` | String(100) | nullable | Tracking provider |
+| `tracking_url` | String(500) | nullable | Full tracking URL |
+| `shipment_number` | String(100) | nullable | Carrier shipment number |
+| `shipping_carrier` | String(50) | nullable | Carrier code (greco, colissimo, etc.) |
+| `customer_notes` | Text | nullable | Notes from customer |
+| `internal_notes` | Text | nullable | Internal notes |
+| `order_date` | DateTime | not null, tz-aware | When customer placed order |
+| `confirmed_at` | DateTime | nullable, tz-aware | When order was confirmed |
+| `shipped_at` | DateTime | nullable, tz-aware | When order was shipped |
+| `delivered_at` | DateTime | nullable, tz-aware | When order was delivered |
+| `cancelled_at` | DateTime | nullable, tz-aware | When order was cancelled |
+| `created_at` | DateTime | tz-aware | Record creation time |
+| `updated_at` | DateTime | tz-aware | Record update time |
+
+**Composite Indexes**: `(store_id, status)`, `(store_id, channel)`, `(store_id, order_date)`
+
+### OrderItem
+
+Individual line items in an order with product snapshot at order time.
+
+| Field | Type | Constraints | Description |
+|-------|------|-------------|-------------|
+| `id` | Integer | PK | Primary key |
+| `order_id` | Integer | FK, not null, indexed | Reference to order |
+| `product_id` | Integer | FK, not null | Reference to product |
+| `product_name` | String(255) | not null | Product name snapshot |
+| `product_sku` | String(100) | nullable | Product SKU snapshot |
+| `gtin` | String(50) | nullable | EAN/UPC/ISBN code |
+| `gtin_type` | String(20) | nullable | GTIN type (ean13, upc, isbn, etc.) |
+| `quantity` | Integer | not null | Units ordered |
+| `unit_price_cents` | Integer | not null | Price per unit in cents |
+| `total_price_cents` | Integer | not null | Total price for line in cents |
+| `external_item_id` | String(100) | nullable | Marketplace inventory unit ID |
+| `external_variant_id` | String(100) | nullable | Marketplace variant ID |
+| `item_state` | String(50) | nullable | confirmed_available or confirmed_unavailable |
+| `inventory_reserved` | Boolean | default False | Whether inventory is reserved |
+| `inventory_fulfilled` | Boolean | default False | Whether inventory is fulfilled |
+| `shipped_quantity` | Integer | default 0, not null | Units shipped so far |
+| `needs_product_match` | Boolean | default False, indexed | Product not found by GTIN during import |
+| `created_at` | DateTime | tz-aware | Record creation time |
+| `updated_at` | DateTime | tz-aware | Record update time |
+
+### OrderItemException
+
+Tracks unmatched order items requiring admin/store resolution. Created when a marketplace order contains a GTIN that doesn't match any product in the store's catalog.
+
+| Field | Type | Constraints | Description |
+|-------|------|-------------|-------------|
+| `id` | Integer | PK | Primary key |
+| `order_item_id` | Integer | FK, unique, not null | Reference to order item (cascade delete) |
+| `store_id` | Integer | FK, not null, indexed | Denormalized store reference |
+| `original_gtin` | String(50) | nullable, indexed | Original GTIN from marketplace |
+| `original_product_name` | String(500) | nullable | Original product name from marketplace |
+| `original_sku` | String(100) | nullable | Original SKU from marketplace |
+| `exception_type` | String(50) | default "product_not_found", not null | product_not_found, gtin_mismatch, duplicate_gtin |
+| `status` | String(50) | default "pending", not null, indexed | pending, resolved, ignored |
+| `resolved_product_id` | Integer | FK, nullable | Assigned product after resolution |
+| `resolved_at` | DateTime | nullable, tz-aware | When exception was resolved |
+| `resolved_by` | Integer | FK, nullable | Who resolved it |
+| `resolution_notes` | Text | nullable | Notes about the resolution |
+| `created_at` | DateTime | tz-aware | Record creation time |
+| `updated_at` | DateTime | tz-aware | Record update time |
+
+**Composite Indexes**: `(store_id, status)`, `(store_id, original_gtin)`
+
+### Invoice
+
+Invoice record with snapshots of seller/buyer details. Stores complete invoice data including snapshots at creation time for audit.
+
+| Field | Type | Constraints | Description |
+|-------|------|-------------|-------------|
+| `id` | Integer | PK | Primary key |
+| `store_id` | Integer | FK, not null, indexed | Reference to store |
+| `order_id` | Integer | FK, nullable, indexed | Reference to order |
+| `invoice_number` | String(50) | not null | Invoice identifier |
+| `invoice_date` | DateTime | not null, tz-aware | Date of invoice |
+| `status` | String(20) | default "draft", not null | draft, issued, paid, cancelled |
+| `seller_details` | JSON | not null | Snapshot: {merchant_name, address, city, postal_code, country, vat_number} |
+| `buyer_details` | JSON | not null | Snapshot: {name, email, address, city, postal_code, country, vat_number} |
+| `line_items` | JSON | not null | Snapshot: [{description, quantity, unit_price_cents, total_cents, sku, ean}] |
+| `vat_regime` | String(20) | default "domestic", not null | domestic, oss, reverse_charge, origin, exempt |
+| `destination_country` | String(2) | nullable | Destination country ISO for OSS |
+| `vat_rate` | Numeric(5,2) | not null | VAT rate percentage |
+| `vat_rate_label` | String(50) | nullable | Human-readable VAT rate label |
+| `currency` | String(3) | default "EUR", not null | Currency code |
+| `subtotal_cents` | Integer | not null | Subtotal before VAT in cents |
+| `vat_amount_cents` | Integer | not null | VAT amount in cents |
+| `total_cents` | Integer | not null | Total after VAT in cents |
+| `payment_terms` | Text | nullable | Payment terms description |
+| `bank_details` | JSON | nullable | IBAN and BIC snapshot |
+| `footer_text` | Text | nullable | Custom footer text |
+| `pdf_generated_at` | DateTime | nullable, tz-aware | When PDF was generated |
+| `pdf_path` | String(500) | nullable | Path to stored PDF |
+| `notes` | Text | nullable | Internal notes |
+| `created_at` | DateTime | tz-aware | Record creation time |
+| `updated_at` | DateTime | tz-aware | Record update time |
+
+**Unique Constraint**: `(store_id, invoice_number)`
+**Composite Indexes**: `(store_id, invoice_date)`, `(store_id, status)`
+
+### StoreInvoiceSettings
+
+Per-store invoice configuration including merchant details, VAT number, invoice numbering, and payment information.
+
+| Field | Type | Constraints | Description |
+|-------|------|-------------|-------------|
+| `id` | Integer | PK | Primary key |
+| `store_id` | Integer | FK, unique, not null | One-to-one with store |
+| `merchant_name` | String(255) | not null | Legal merchant name |
+| `merchant_address` | String(255) | nullable | Street address |
+| `merchant_city` | String(100) | nullable | City |
+| `merchant_postal_code` | String(20) | nullable | Postal code |
+| `merchant_country` | String(2) | default "LU", not null | ISO country code |
+| `vat_number` | String(50) | nullable | VAT number (e.g., "LU12345678") |
+| `is_vat_registered` | Boolean | default True, not null | VAT registration status |
+| `is_oss_registered` | Boolean | default False, not null | OSS registration status |
+| `oss_registration_country` | String(2) | nullable | OSS registration country |
+| `invoice_prefix` | String(20) | default "INV", not null | Invoice number prefix |
+| `invoice_next_number` | Integer | default 1, not null | Next invoice number counter |
+| `invoice_number_padding` | Integer | default 5, not null | Zero-padding width |
+| `payment_terms` | Text | nullable | Payment terms description |
+| `bank_name` | String(255) | nullable | Bank name |
+| `bank_iban` | String(50) | nullable | IBAN |
+| `bank_bic` | String(20) | nullable | BIC |
+| `footer_text` | Text | nullable | Custom footer text |
+| `default_vat_rate` | Numeric(5,2) | default 17.00, not null | Default VAT rate |
+| `created_at` | DateTime | tz-aware | Record creation time |
+| `updated_at` | DateTime | tz-aware | Record update time |
+
+### CustomerOrderStats
+
+Aggregated order statistics per customer per store. Separates order stats from customer profile data.
+
+| Field | Type | Constraints | Description |
+|-------|------|-------------|-------------|
+| `id` | Integer | PK | Primary key |
+| `store_id` | Integer | FK, not null, indexed | Reference to store |
+| `customer_id` | Integer | FK, not null, indexed | Reference to customer |
+| `total_orders` | Integer | default 0, not null | Total number of orders |
+| `total_spent_cents` | Integer | default 0, not null | Total amount spent in cents |
+| `last_order_date` | DateTime | nullable, tz-aware | Date of most recent order |
+| `first_order_date` | DateTime | nullable, tz-aware | Date of first order |
+| `created_at` | DateTime | tz-aware | Record creation time |
+| `updated_at` | DateTime | tz-aware | Record update time |
+
+**Unique Constraint**: `(store_id, customer_id)`
+
+## Enums
+
+### InvoiceStatus
+
+| Value | Description |
+|-------|-------------|
+| `draft` | Invoice created but not finalized |
+| `issued` | Invoice sent to customer |
+| `paid` | Payment received |
+| `cancelled` | Invoice cancelled |
+
+### VATRegime
+
+| Value | Description |
+|-------|-------------|
+| `domestic` | Same country as seller |
+| `oss` | EU cross-border with OSS registration |
+| `reverse_charge` | B2B with valid VAT number |
+| `origin` | Cross-border without OSS (use origin VAT) |
+| `exempt` | VAT exempt |
+
+## Design Patterns
+
+- **Money as cents**: All monetary values stored as integer cents for precision
+- **Snapshots**: Customer, address, seller, and product data captured at order/invoice time
+- **Marketplace support**: External reference fields for marketplace-specific IDs and data
+- **Timezone-aware dates**: All DateTime fields include timezone info
+- **Composite indexes**: Optimized for common query patterns (store + status, store + date)
diff --git a/app/modules/orders/docs/exceptions.md b/app/modules/orders/docs/exceptions.md
new file mode 100644
index 00000000..9fa426b9
--- /dev/null
+++ b/app/modules/orders/docs/exceptions.md
@@ -0,0 +1,288 @@
+# Order Item Exception System
+
+## Overview
+
+The Order Item Exception system handles unmatched products during marketplace order imports. Instead of blocking imports when products cannot be found by GTIN, the system gracefully imports orders with placeholder products and creates exception records for QC resolution.
+
+## Design Principles
+
+1. **Graceful Import** - Orders are imported even when products aren't found
+2. **Exception Tracking** - Unmatched items are tracked in `order_item_exceptions` table
+3. **Resolution Workflow** - Admin/store can assign correct products
+4. **Confirmation Blocking** - Orders with unresolved exceptions cannot be confirmed
+5. **Auto-Match** - Exceptions auto-resolve when matching products are imported
+
+## Database Schema
+
+### order_item_exceptions Table
+
+| Column | Type | Description |
+|--------|------|-------------|
+| id | Integer | Primary key |
+| order_item_id | Integer | FK to order_items (unique) |
+| store_id | Integer | FK to stores (indexed) |
+| original_gtin | String(50) | GTIN from marketplace |
+| original_product_name | String(500) | Product name from marketplace |
+| original_sku | String(100) | SKU from marketplace |
+| exception_type | String(50) | product_not_found, gtin_mismatch, duplicate_gtin |
+| status | String(50) | pending, resolved, ignored |
+| resolved_product_id | Integer | FK to products (nullable) |
+| resolved_at | DateTime | When resolved |
+| resolved_by | Integer | FK to users |
+| resolution_notes | Text | Optional notes |
+| created_at | DateTime | Created timestamp |
+| updated_at | DateTime | Updated timestamp |
+
+### order_items Table (Modified)
+
+Added column:
+- `needs_product_match: Boolean (default False, indexed)`
+
+### Placeholder Product
+
+Per-store placeholder with:
+- `gtin = "0000000000000"`
+- `gtin_type = "placeholder"`
+- `is_active = False`
+
+## Workflow
+
+```
+Import Order from Marketplace
+ │
+ ▼
+ Query Products by GTIN
+ │
+ ┌────┴────┐
+ │ │
+ Found Not Found
+ │ │
+ ▼ ▼
+ Normal Create with placeholder
+ Item + Set needs_product_match=True
+ + Create OrderItemException
+ │
+ ▼
+ QC Dashboard shows pending
+ │
+ ┌─────┴─────┐
+ │ │
+ Resolve Ignore
+ (assign (with
+ product) reason)
+ │ │
+ ▼ ▼
+ Update item Mark ignored
+ product_id (still blocks)
+ │
+ ▼
+ Order can now be confirmed
+```
+
+## API Endpoints
+
+### Admin Endpoints
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| GET | `/api/v1/admin/order-exceptions` | List all exceptions |
+| GET | `/api/v1/admin/order-exceptions/stats` | Get exception statistics |
+| GET | `/api/v1/admin/order-exceptions/{id}` | Get exception details |
+| POST | `/api/v1/admin/order-exceptions/{id}/resolve` | Resolve with product |
+| POST | `/api/v1/admin/order-exceptions/{id}/ignore` | Mark as ignored |
+| POST | `/api/v1/admin/order-exceptions/bulk-resolve` | Bulk resolve by GTIN |
+
+### Store Endpoints
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| GET | `/api/v1/store/order-exceptions` | List store's exceptions |
+| GET | `/api/v1/store/order-exceptions/stats` | Get store's stats |
+| GET | `/api/v1/store/order-exceptions/{id}` | Get exception details |
+| POST | `/api/v1/store/order-exceptions/{id}/resolve` | Resolve with product |
+| POST | `/api/v1/store/order-exceptions/{id}/ignore` | Mark as ignored |
+| POST | `/api/v1/store/order-exceptions/bulk-resolve` | Bulk resolve by GTIN |
+
+## 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 |
+
+**Note:** Both `pending` and `ignored` statuses block order confirmation.
+
+## Auto-Matching
+
+When products are imported to the store catalog (via copy_to_store_catalog), 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 automatically during:
+- Single product import
+- Bulk product import (marketplace sync)
+
+## Integration Points
+
+### Order Creation (`app/services/order_service.py`)
+
+The `create_letzshop_order()` method:
+1. Queries products by GTIN
+2. For missing GTINs, creates placeholder product
+3. Creates order items with `needs_product_match=True`
+4. Creates exception records
+
+### Order Confirmation
+
+Confirmation endpoints check for unresolved exceptions:
+- Admin: `app/api/v1/admin/letzshop.py`
+- Store: `app/api/v1/store/letzshop.py`
+
+Raises `OrderHasUnresolvedExceptionsException` if exceptions exist.
+
+### Product Import (`app/services/marketplace_product_service.py`)
+
+The `copy_to_store_catalog()` method:
+1. Copies GTIN from MarketplaceProduct to Product
+2. Calls auto-match service after products are created
+3. Returns `auto_matched` count in response
+
+## Files Created/Modified
+
+### New Files
+
+| File | Description |
+|------|-------------|
+| `models/database/order_item_exception.py` | Database model |
+| `models/schema/order_item_exception.py` | Pydantic schemas |
+| `app/services/order_item_exception_service.py` | Business logic |
+| `app/exceptions/order_item_exception.py` | Domain exceptions |
+| `app/api/v1/admin/order_item_exceptions.py` | Admin endpoints |
+| `app/api/v1/store/order_item_exceptions.py` | Store endpoints |
+| `alembic/versions/d2e3f4a5b6c7_add_order_item_exceptions.py` | Migration |
+
+### Modified Files
+
+| File | Changes |
+|------|---------|
+| `models/database/order.py` | Added `needs_product_match`, exception relationship |
+| `models/database/__init__.py` | Export OrderItemException |
+| `models/schema/order.py` | Added exception info to OrderItemResponse |
+| `app/services/order_service.py` | Graceful handling of missing products |
+| `app/services/marketplace_product_service.py` | Auto-match on product import |
+| `app/api/v1/admin/letzshop.py` | Confirmation blocking check |
+| `app/api/v1/store/letzshop.py` | Confirmation blocking check |
+| `app/api/v1/admin/__init__.py` | Register exception router |
+| `app/api/v1/store/__init__.py` | Register exception router |
+| `app/exceptions/__init__.py` | Export new exceptions |
+
+## Response Examples
+
+### List Exceptions
+
+```json
+{
+ "exceptions": [
+ {
+ "id": 1,
+ "order_item_id": 42,
+ "store_id": 1,
+ "original_gtin": "4006381333931",
+ "original_product_name": "Funko Pop! Marvel...",
+ "original_sku": "MH-FU-56757",
+ "exception_type": "product_not_found",
+ "status": "pending",
+ "order_number": "LS-1-R702236251",
+ "order_date": "2025-12-19T10:30:00Z",
+ "created_at": "2025-12-19T11:00:00Z"
+ }
+ ],
+ "total": 15,
+ "skip": 0,
+ "limit": 50
+}
+```
+
+### Exception Stats
+
+```json
+{
+ "pending": 15,
+ "resolved": 42,
+ "ignored": 3,
+ "total": 60,
+ "orders_with_exceptions": 8
+}
+```
+
+### Resolve Exception
+
+```json
+POST /api/v1/admin/order-exceptions/1/resolve
+{
+ "product_id": 123,
+ "notes": "Matched to correct product manually"
+}
+```
+
+### Bulk Resolve
+
+```json
+POST /api/v1/admin/order-exceptions/bulk-resolve?store_id=1
+{
+ "gtin": "4006381333931",
+ "product_id": 123,
+ "notes": "New product imported"
+}
+
+Response:
+{
+ "resolved_count": 5,
+ "gtin": "4006381333931",
+ "product_id": 123
+}
+```
+
+## Admin UI
+
+The exceptions tab is available in the Letzshop management page:
+
+**Location:** `/admin/marketplace/letzshop` → Exceptions tab
+
+### Features
+
+- **Stats Cards**: Shows pending, resolved, ignored, and affected orders counts
+- **Filters**: Search by GTIN/product name/order number, filter by status
+- **Exception Table**: Paginated list with product info, GTIN, order link, status
+- **Actions**:
+ - **Resolve**: Opens modal with product search (autocomplete)
+ - **Ignore**: Marks exception as ignored (still blocks confirmation)
+ - **Bulk Resolve**: Checkbox to apply resolution to all exceptions with same GTIN
+
+### Files
+
+| File | Description |
+|------|-------------|
+| `app/templates/admin/partials/letzshop-exceptions-tab.html` | Tab HTML template |
+| `app/templates/admin/marketplace-letzshop.html` | Main page (includes tab) |
+| `static/admin/js/marketplace-letzshop.js` | JavaScript handlers |
+
+## Error Handling
+
+| Exception | HTTP Status | When |
+|-----------|-------------|------|
+| `OrderItemExceptionNotFoundException` | 404 | Exception not found |
+| `OrderHasUnresolvedExceptionsException` | 400 | Trying to confirm order with exceptions |
+| `ExceptionAlreadyResolvedException` | 400 | Trying to resolve already resolved exception |
+| `InvalidProductForExceptionException` | 400 | Invalid product (wrong store, inactive) |
diff --git a/app/modules/orders/docs/index.md b/app/modules/orders/docs/index.md
new file mode 100644
index 00000000..f29a7cf7
--- /dev/null
+++ b/app/modules/orders/docs/index.md
@@ -0,0 +1,67 @@
+# Order Management
+
+Order processing, fulfillment tracking, customer checkout, invoicing, and bulk order operations. Uses the payments module for checkout.
+
+## Overview
+
+| Aspect | Detail |
+|--------|--------|
+| Code | `orders` |
+| Classification | Optional |
+| Dependencies | `payments`, `catalog`, `inventory`, `marketplace` |
+| Status | Active |
+
+## Features
+
+- `order_management` — Order CRUD and status management
+- `order_bulk_actions` — Bulk order operations
+- `order_export` — Order data export
+- `automation_rules` — Order processing automation
+- `fulfillment_tracking` — Shipment and fulfillment tracking
+- `shipping_management` — Shipping method configuration
+- `order_exceptions` — Order item exception handling
+- `customer_checkout` — Customer-facing checkout
+- `invoice_generation` — Automatic invoice creation
+- `invoice_pdf` — PDF invoice generation
+
+## Permissions
+
+| Permission | Description |
+|------------|-------------|
+| `orders.view` | View orders |
+| `orders.edit` | Edit orders |
+| `orders.cancel` | Cancel orders |
+| `orders.refund` | Process refunds |
+
+## Data Model
+
+See [Data Model](data-model.md) for full entity relationships and schema.
+
+- **Order** — Unified order model for direct and marketplace channels
+- **OrderItem** — Line items with product snapshots and shipment tracking
+- **OrderItemException** — Unmatched GTIN resolution for marketplace imports
+- **Invoice** — Invoice records with seller/buyer snapshots
+- **StoreInvoiceSettings** — Per-store invoice configuration and VAT settings
+- **CustomerOrderStats** — Aggregated per-customer order statistics
+
+## API Endpoints
+
+| Method | Path | Description |
+|--------|------|-------------|
+| `*` | `/api/v1/admin/orders/*` | Admin order management |
+| `*` | `/api/v1/admin/order-exceptions/*` | Exception management |
+| `*` | `/api/v1/store/customer-orders/*` | Store customer order views |
+
+## Configuration
+
+No module-specific configuration.
+
+## Additional Documentation
+
+- [Data Model](data-model.md) — Entity relationships and database schema
+- [Architecture](architecture.md) — Consumer-agnostic customer architecture
+- [Unified Order View](unified-order-view.md) — Unified order schema with snapshots
+- [Order Item Exceptions](exceptions.md) — Exception system for unmatched products
+- [OMS Feature Plan](oms-features.md) — Order management system roadmap
+- [VAT Invoicing](vat-invoicing.md) — VAT decision tree and invoice generation
+- [Stock Integration](stock-integration.md) — Order-inventory synchronization
diff --git a/app/modules/orders/docs/oms-features.md b/app/modules/orders/docs/oms-features.md
new file mode 100644
index 00000000..a9053178
--- /dev/null
+++ b/app/modules/orders/docs/oms-features.md
@@ -0,0 +1,662 @@
+# OMS Feature Implementation Plan
+
+## Overview
+
+Transform Orion into a **"Lightweight OMS for Letzshop Sellers"** by building the missing features that justify the tier pricing structure.
+
+**Goal:** Ship Essential tier quickly, then build Professional differentiators, then Business features.
+
+## Design Decisions (Confirmed)
+
+| Decision | Choice |
+|----------|--------|
+| Phase 1 scope | Invoicing + Tier Limits together |
+| PDF library | WeasyPrint (HTML/CSS to PDF) |
+| Invoice style | Simple & Clean (minimal design) |
+
+---
+
+## Current State Summary
+
+### Already Production-Ready
+- Multi-tenant architecture (Merchant → Store hierarchy)
+- Letzshop order sync, confirmation, tracking
+- Inventory management with locations and reservations
+- Unified Order model (direct + marketplace)
+- Customer model with pre-calculated stats (total_orders, total_spent)
+- Team management + RBAC
+- CSV export patterns (products)
+
+### Needs to be Built
+| Feature | Tier Impact | Priority |
+|---------|-------------|----------|
+| Basic LU Invoice (PDF) | Essential | P0 |
+| Tier limits enforcement | Essential | P0 |
+| Store VAT Settings | Professional | P1 |
+| EU VAT Invoice | Professional | P1 |
+| Incoming Stock / PO | Professional | P1 |
+| Customer CSV Export | Professional | P1 |
+| Multi-store view | Business | P2 |
+| Accounting export | Business | P2 |
+
+---
+
+## Phase 1: Essential Tier (Target: 1 week)
+
+**Goal:** Launch Essential (€49) with basic invoicing and tier enforcement.
+
+### Step 1.1: Store Invoice Settings (1 day)
+
+**Create model for store billing details:**
+
+```
+models/database/store_invoice_settings.py
+```
+
+Fields:
+- `store_id` (FK, unique - one-to-one)
+- `merchant_name` (legal name for invoices)
+- `merchant_address`, `merchant_city`, `merchant_postal_code`, `merchant_country`
+- `vat_number` (e.g., "LU12345678")
+- `invoice_prefix` (default "INV")
+- `invoice_next_number` (auto-increment)
+- `payment_terms` (optional text)
+- `bank_details` (optional IBAN etc.)
+- `footer_text` (optional)
+
+**Pattern to follow:** `models/database/letzshop.py` (StoreLetzshopCredentials)
+
+**Files to create/modify:**
+- `models/database/store_invoice_settings.py` (new)
+- `models/database/__init__.py` (add import)
+- `models/database/store.py` (add relationship)
+- `models/schema/invoice.py` (new - Pydantic schemas)
+- `alembic/versions/xxx_add_store_invoice_settings.py` (migration)
+
+---
+
+### Step 1.2: Basic Invoice Model (0.5 day)
+
+**Create invoice storage:**
+
+```
+models/database/invoice.py
+```
+
+Fields:
+- `id`, `store_id` (FK)
+- `order_id` (FK, nullable - for manual invoices later)
+- `invoice_number` (unique per store)
+- `invoice_date`
+- `seller_details` (JSONB snapshot)
+- `buyer_details` (JSONB snapshot)
+- `line_items` (JSONB snapshot)
+- `subtotal_cents`, `vat_rate`, `vat_amount_cents`, `total_cents`
+- `currency` (default EUR)
+- `status` (draft, issued, paid, cancelled)
+- `pdf_generated_at`, `pdf_path` (optional)
+
+**Files to create/modify:**
+- `models/database/invoice.py` (new)
+- `models/database/__init__.py` (add import)
+- `alembic/versions/xxx_add_invoices_table.py` (migration)
+
+---
+
+### Step 1.3: Invoice Service - Basic LU Only (1 day)
+
+**Create service for invoice generation:**
+
+```
+app/services/invoice_service.py
+```
+
+Methods:
+- `create_invoice_from_order(order_id, store_id)` - Generate invoice from order
+- `get_invoice(invoice_id, store_id)` - Retrieve invoice
+- `list_invoices(store_id, skip, limit)` - List store invoices
+- `_generate_invoice_number(settings)` - Auto-increment number
+- `_snapshot_seller(settings)` - Capture store details
+- `_snapshot_buyer(order)` - Capture customer details
+- `_calculate_totals(order)` - Calculate with LU VAT (17%)
+
+**For Essential tier:** Fixed 17% Luxembourg VAT only. EU VAT comes in Professional.
+
+**Files to create:**
+- `app/services/invoice_service.py` (new)
+
+---
+
+### Step 1.4: PDF Generation (1.5 days)
+
+**Add WeasyPrint dependency and create PDF service:**
+
+```
+app/services/invoice_pdf_service.py
+```
+
+Methods:
+- `generate_pdf(invoice)` - Returns PDF bytes
+- `_render_html(invoice)` - Jinja2 template rendering
+
+**Template:**
+```
+app/templates/invoices/invoice.html
+```
+
+Simple, clean invoice layout:
+- Seller details (top left)
+- Buyer details (top right)
+- Invoice number + date
+- Line items table
+- Totals with VAT breakdown
+- Footer (payment terms, bank details)
+
+**Files to create/modify:**
+- `requirements.txt` (add weasyprint)
+- `app/services/invoice_pdf_service.py` (new)
+- `app/templates/invoices/invoice.html` (new)
+- `app/templates/invoices/invoice.css` (new, optional)
+
+---
+
+### Step 1.5: Invoice API Endpoints (0.5 day)
+
+**Create store invoice endpoints:**
+
+```
+app/api/v1/store/invoices.py
+```
+
+Endpoints:
+- `POST /orders/{order_id}/invoice` - Generate invoice for order
+- `GET /invoices` - List invoices
+- `GET /invoices/{invoice_id}` - Get invoice details
+- `GET /invoices/{invoice_id}/pdf` - Download PDF
+
+**Files to create/modify:**
+- `app/api/v1/store/invoices.py` (new)
+- `app/api/v1/store/__init__.py` (add router)
+
+---
+
+### Step 1.6: Invoice Settings UI (0.5 day)
+
+**Add invoice settings to store settings page:**
+
+Modify existing store settings template to add "Invoice Settings" section:
+- Merchant name, address fields
+- VAT number
+- Invoice prefix
+- Payment terms
+- Bank details
+
+**Files to modify:**
+- `app/templates/store/settings.html` (add section)
+- `static/store/js/settings.js` (add handlers)
+- `app/api/v1/store/settings.py` (add endpoints if needed)
+
+---
+
+### Step 1.7: Order Detail - Invoice Button (0.5 day)
+
+**Add "Generate Invoice" / "Download Invoice" button to order detail:**
+
+- If no invoice: Show "Generate Invoice" button
+- If invoice exists: Show "Download Invoice" link
+
+**Files to modify:**
+- `app/templates/store/order-detail.html` (add button)
+- `static/store/js/order-detail.js` (add handler)
+
+---
+
+### Step 1.8: Tier Limits Enforcement (1 day)
+
+**Create tier/subscription model:**
+
+```
+models/database/store_subscription.py
+```
+
+Fields:
+- `store_id` (FK, unique)
+- `tier` (essential, professional, business)
+- `orders_this_month` (counter, reset monthly)
+- `period_start`, `period_end`
+- `is_active`
+
+**Create limits service:**
+
+```
+app/services/tier_limits_service.py
+```
+
+Methods:
+- `check_order_limit(store_id)` - Returns (allowed: bool, remaining: int)
+- `increment_order_count(store_id)` - Called when order synced
+- `get_tier_limits(tier)` - Returns limit config
+- `reset_monthly_counters()` - Cron job
+
+**Tier limits:**
+| Tier | Orders/month | Products |
+|------|--------------|----------|
+| Essential | 100 | 200 |
+| Professional | 500 | Unlimited |
+| Business | Unlimited | Unlimited |
+
+**Integration points:**
+- `order_service.py` - Check limit before creating order
+- Letzshop sync - Check limit before importing
+
+**Files to create/modify:**
+- `models/database/store_subscription.py` (new)
+- `app/services/tier_limits_service.py` (new)
+- `app/services/order_service.py` (add limit check)
+- `app/services/letzshop/order_service.py` (add limit check)
+
+---
+
+## Phase 2: Professional Tier (Target: 2 weeks)
+
+**Goal:** Build the differentiating features that justify €99/month.
+
+### Step 2.1: EU VAT Rates Table (0.5 day)
+
+**Create VAT rates reference table:**
+
+```
+models/database/eu_vat_rates.py
+```
+
+Fields:
+- `country_code` (LU, DE, FR, etc.)
+- `country_name`
+- `standard_rate` (decimal)
+- `reduced_rate_1`, `reduced_rate_2` (optional)
+- `effective_from`, `effective_until`
+
+**Seed with current EU rates (27 countries).**
+
+**Files to create:**
+- `models/database/eu_vat_rates.py` (new)
+- `alembic/versions/xxx_add_eu_vat_rates.py` (migration + seed)
+
+---
+
+### Step 2.2: Enhanced Store VAT Settings (0.5 day)
+
+**Add OSS fields to StoreInvoiceSettings:**
+
+- `is_oss_registered` (boolean)
+- `oss_registration_country` (if different from merchant country)
+
+**Files to modify:**
+- `models/database/store_invoice_settings.py` (add fields)
+- `alembic/versions/xxx_add_oss_fields.py` (migration)
+
+---
+
+### Step 2.3: VAT Service (1 day)
+
+**Create VAT calculation service:**
+
+```
+app/services/vat_service.py
+```
+
+Methods:
+- `get_vat_rate(country_code, as_of_date)` - Lookup rate
+- `determine_vat_regime(seller_country, buyer_country, buyer_vat_number, is_oss)` - Returns (regime, rate)
+- `validate_vat_number(vat_number)` - Format check (VIES integration later)
+
+**VAT Decision Logic:**
+1. B2B with valid VAT number → Reverse charge (0%)
+2. Domestic sale → Domestic VAT
+3. Cross-border + OSS registered → Destination VAT
+4. Cross-border + under threshold → Origin VAT
+
+**Files to create:**
+- `app/services/vat_service.py` (new)
+
+---
+
+### Step 2.4: Enhanced Invoice Service (1 day)
+
+**Upgrade invoice service for EU VAT:**
+
+- Add `vat_regime` field to invoice (domestic, oss, reverse_charge, origin)
+- Add `destination_country` field
+- Use VATService to calculate correct rate
+- Update invoice template for regime-specific text
+
+**Files to modify:**
+- `models/database/invoice.py` (add fields)
+- `app/services/invoice_service.py` (use VATService)
+- `app/templates/invoices/invoice.html` (add regime text)
+- `alembic/versions/xxx_add_vat_regime_to_invoices.py`
+
+---
+
+### Step 2.5: Purchase Order Model (1 day)
+
+**Create purchase order tracking:**
+
+```
+models/database/purchase_order.py
+```
+
+**PurchaseOrder:**
+- `id`, `store_id` (FK)
+- `po_number` (auto-generated)
+- `supplier_name` (free text for now)
+- `status` (draft, ordered, partial, received, cancelled)
+- `order_date`, `expected_date`
+- `notes`
+
+**PurchaseOrderItem:**
+- `purchase_order_id` (FK)
+- `product_id` (FK)
+- `quantity_ordered`
+- `quantity_received`
+- `unit_cost_cents` (optional)
+
+**Files to create:**
+- `models/database/purchase_order.py` (new)
+- `models/database/__init__.py` (add import)
+- `models/schema/purchase_order.py` (new)
+- `alembic/versions/xxx_add_purchase_orders.py`
+
+---
+
+### Step 2.6: Purchase Order Service (1 day)
+
+**Create PO management service:**
+
+```
+app/services/purchase_order_service.py
+```
+
+Methods:
+- `create_purchase_order(store_id, data)` - Create PO
+- `add_item(po_id, product_id, quantity)` - Add line item
+- `receive_items(po_id, items)` - Mark items received, update inventory
+- `get_incoming_stock(store_id)` - Summary of pending stock
+- `list_purchase_orders(store_id, status, skip, limit)`
+
+**Integration:** When items received → call `inventory_service.adjust_inventory()`
+
+**Files to create:**
+- `app/services/purchase_order_service.py` (new)
+
+---
+
+### Step 2.7: Purchase Order UI (1.5 days)
+
+**Create PO management page:**
+
+```
+app/templates/store/purchase-orders.html
+```
+
+Features:
+- List POs with status
+- Create new PO (select products, quantities, expected date)
+- Receive items (partial or full)
+- View incoming stock summary
+
+**Inventory page enhancement:**
+- Show "On Order" column in inventory list
+- Query: SUM of quantity_ordered - quantity_received for pending POs
+
+**Files to create/modify:**
+- `app/templates/store/purchase-orders.html` (new)
+- `static/store/js/purchase-orders.js` (new)
+- `app/api/v1/store/purchase_orders.py` (new endpoints)
+- `app/routes/store_pages.py` (add route)
+- `app/templates/store/partials/sidebar.html` (add menu item)
+- `app/templates/store/inventory.html` (add On Order column)
+
+---
+
+### Step 2.8: Customer Export Service (1 day)
+
+**Create customer export functionality:**
+
+```
+app/services/customer_export_service.py
+```
+
+Methods:
+- `export_customers_csv(store_id, filters)` - Returns CSV string
+
+**CSV Columns:**
+- email, first_name, last_name, phone
+- customer_number
+- total_orders, total_spent, avg_order_value
+- first_order_date, last_order_date
+- preferred_language
+- marketing_consent
+- tags (if we add tagging)
+
+**Files to create:**
+- `app/services/customer_export_service.py` (new)
+
+---
+
+### Step 2.9: Customer Export API + UI (0.5 day)
+
+**Add export endpoint:**
+
+```
+GET /api/v1/store/customers/export?format=csv
+```
+
+**Add export button to customers page:**
+- "Export to CSV" button
+- Downloads file directly
+
+**Files to modify:**
+- `app/api/v1/store/customers.py` (add export endpoint)
+- `app/templates/store/customers.html` (add button)
+
+---
+
+## Phase 3: Business Tier (Target: 1-2 weeks)
+
+**Goal:** Build features for teams and high-volume operations.
+
+### Step 3.1: Multi-Store Consolidated View (2 days)
+
+**For merchants with multiple Letzshop accounts:**
+
+**New page:**
+```
+app/templates/store/multi-store-dashboard.html
+```
+
+Features:
+- See all store accounts under same merchant
+- Consolidated order count, revenue
+- Switch between store contexts
+- Unified reporting
+
+**Requires:** Merchant-level authentication context (already exists via Merchant → Store relationship)
+
+**Files to create/modify:**
+- `app/templates/store/multi-store-dashboard.html` (new)
+- `app/services/multi_store_service.py` (new)
+- `app/api/v1/store/multi_store.py` (new)
+
+---
+
+### Step 3.2: Accounting Export (1 day)
+
+**Export invoices in accounting-friendly formats:**
+
+```
+app/services/accounting_export_service.py
+```
+
+Methods:
+- `export_invoices_csv(store_id, date_from, date_to)` - Simple CSV
+- `export_invoices_xml(store_id, date_from, date_to)` - For accounting software
+
+**CSV format for accountants:**
+- invoice_number, invoice_date
+- customer_name, customer_vat
+- subtotal, vat_rate, vat_amount, total
+- currency, status
+
+**Files to create:**
+- `app/services/accounting_export_service.py` (new)
+- `app/api/v1/store/accounting.py` (new endpoints)
+
+---
+
+### Step 3.3: API Access Documentation (1 day)
+
+**If not already documented, create API documentation page:**
+
+- Document existing store API endpoints
+- Add rate limiting for API tier
+- Generate API keys for stores
+
+**Files to create/modify:**
+- `docs/api/store-api.md` (documentation)
+- `app/services/api_key_service.py` (if needed)
+
+---
+
+## Implementation Order Summary
+
+### Week 1: Essential Tier
+| Day | Task | Deliverable |
+|-----|------|-------------|
+| 1 | Step 1.1 | Store Invoice Settings model |
+| 1 | Step 1.2 | Invoice model |
+| 2 | Step 1.3 | Invoice Service (LU only) |
+| 3-4 | Step 1.4 | PDF Generation |
+| 4 | Step 1.5 | Invoice API |
+| 5 | Step 1.6 | Invoice Settings UI |
+| 5 | Step 1.7 | Order Detail button |
+
+### Week 2: Tier Limits + EU VAT Start
+| Day | Task | Deliverable |
+|-----|------|-------------|
+| 1 | Step 1.8 | Tier limits enforcement |
+| 2 | Step 2.1 | EU VAT rates table |
+| 2 | Step 2.2 | OSS fields |
+| 3 | Step 2.3 | VAT Service |
+| 4 | Step 2.4 | Enhanced Invoice Service |
+| 5 | Testing | End-to-end invoice testing |
+
+### Week 3: Purchase Orders + Customer Export
+| Day | Task | Deliverable |
+|-----|------|-------------|
+| 1 | Step 2.5 | Purchase Order model |
+| 2 | Step 2.6 | Purchase Order service |
+| 3-4 | Step 2.7 | Purchase Order UI |
+| 5 | Step 2.8-2.9 | Customer Export |
+
+### Week 4: Business Tier
+| Day | Task | Deliverable |
+|-----|------|-------------|
+| 1-2 | Step 3.1 | Multi-store view |
+| 3 | Step 3.2 | Accounting export |
+| 4 | Step 3.3 | API documentation |
+| 5 | Testing + Polish | Full testing |
+
+---
+
+## Key Files Reference
+
+### Models to Create
+- `models/database/store_invoice_settings.py`
+- `models/database/invoice.py`
+- `models/database/eu_vat_rates.py`
+- `models/database/store_subscription.py`
+- `models/database/purchase_order.py`
+
+### Services to Create
+- `app/services/invoice_service.py`
+- `app/services/invoice_pdf_service.py`
+- `app/services/vat_service.py`
+- `app/services/tier_limits_service.py`
+- `app/services/purchase_order_service.py`
+- `app/services/customer_export_service.py`
+- `app/services/accounting_export_service.py`
+
+### Templates to Create
+- `app/templates/invoices/invoice.html`
+- `app/templates/store/purchase-orders.html`
+
+### Existing Files to Modify
+- `models/database/__init__.py`
+- `models/database/store.py`
+- `app/services/order_service.py`
+- `app/templates/store/settings.html`
+- `app/templates/store/order-detail.html`
+- `app/templates/store/inventory.html`
+- `app/templates/store/customers.html`
+- `requirements.txt`
+
+---
+
+## Dependencies to Add
+
+```
+# requirements.txt
+weasyprint>=60.0
+```
+
+**Note:** WeasyPrint requires system dependencies:
+- `libpango-1.0-0`
+- `libpangocairo-1.0-0`
+- `libgdk-pixbuf2.0-0`
+
+Add to Dockerfile if deploying via Docker.
+
+---
+
+## Testing Strategy
+
+### Unit Tests
+- `tests/unit/services/test_invoice_service.py`
+- `tests/unit/services/test_vat_service.py`
+- `tests/unit/services/test_tier_limits_service.py`
+- `tests/unit/services/test_purchase_order_service.py`
+
+### Integration Tests
+- `tests/integration/api/v1/store/test_invoices.py`
+- `tests/integration/api/v1/store/test_purchase_orders.py`
+
+### Manual Testing
+- Generate invoice for LU customer
+- Generate invoice for DE customer (OSS)
+- Generate invoice for B2B with VAT number (reverse charge)
+- Create PO, receive items, verify inventory update
+- Export customers CSV, import to Mailchimp
+
+---
+
+## Success Criteria
+
+### Essential Tier Ready When:
+- [ ] Can generate PDF invoice from order (LU VAT)
+- [ ] Invoice settings page works
+- [ ] Order detail shows invoice button
+- [ ] Tier limits enforced on order sync
+
+### Professional Tier Ready When:
+- [ ] EU VAT calculated correctly by destination
+- [ ] OSS regime supported
+- [ ] Reverse charge for B2B supported
+- [ ] Purchase orders can be created and received
+- [ ] Incoming stock shows in inventory
+- [ ] Customer export to CSV works
+
+### Business Tier Ready When:
+- [ ] Multi-store dashboard works
+- [ ] Accounting export works
+- [ ] API access documented
diff --git a/app/modules/orders/docs/stock-integration.md b/app/modules/orders/docs/stock-integration.md
new file mode 100644
index 00000000..d8669f9c
--- /dev/null
+++ b/app/modules/orders/docs/stock-integration.md
@@ -0,0 +1,371 @@
+# Stock Management Integration
+
+**Created:** January 1, 2026
+**Status:** Implemented
+
+## Overview
+
+This document describes the automatic inventory synchronization between orders and stock levels. When order status changes, inventory is automatically updated to maintain accurate stock counts.
+
+## Architecture
+
+### Services Involved
+
+```
+OrderService OrderInventoryService
+ │ │
+ ├─ update_order_status() ──────────► handle_status_change()
+ │ │
+ │ ├─► reserve_for_order()
+ │ ├─► fulfill_order()
+ │ └─► release_order_reservation()
+ │ │
+ │ ▼
+ │ InventoryService
+ │ │
+ │ ├─► reserve_inventory()
+ │ ├─► fulfill_reservation()
+ │ └─► release_reservation()
+```
+
+### Key Files
+
+| File | Purpose |
+|------|---------|
+| `app/services/order_inventory_service.py` | Orchestrates order-inventory operations |
+| `app/services/order_service.py` | Calls inventory hooks on status change |
+| `app/services/inventory_service.py` | Low-level inventory operations |
+
+## Status Change Inventory Actions
+
+| Status Transition | Inventory Action | Description |
+|-------------------|------------------|-------------|
+| Any → `processing` | Reserve | Reserves stock for order items |
+| Any → `shipped` | Fulfill | Deducts from stock and releases reservation |
+| Any → `cancelled` | Release | Returns reserved stock to available |
+
+## Inventory Operations
+
+### Reserve Inventory
+
+When an order status changes to `processing`:
+
+1. For each order item:
+ - Find inventory record with available quantity
+ - Increase `reserved_quantity` by item quantity
+ - Log the reservation
+
+2. Placeholder products (unmatched Letzshop items) are skipped
+
+### Fulfill Inventory
+
+When an order status changes to `shipped`:
+
+1. For each order item:
+ - Decrease `quantity` by item quantity (stock consumed)
+ - Decrease `reserved_quantity` accordingly
+ - Log the fulfillment
+
+### Release Reservation
+
+When an order is `cancelled`:
+
+1. For each order item:
+ - Decrease `reserved_quantity` (stock becomes available again)
+ - Total `quantity` remains unchanged
+ - Log the release
+
+## Error Handling
+
+Inventory operations use **soft failure** - if inventory cannot be updated:
+
+1. Warning is logged
+2. Order status update continues
+3. Inventory can be manually adjusted
+
+This ensures orders are never blocked by inventory issues while providing visibility into any problems.
+
+## Edge Cases
+
+### Placeholder Products
+
+Letzshop orders may contain unmatched GTINs that map to placeholder products. These are identified by:
+- GTIN `0000000000000`
+- Product linked to placeholder MarketplaceProduct
+
+Inventory operations skip placeholder products since they have no real stock.
+
+### Missing Inventory
+
+If a product has no inventory record:
+- Operation is skipped with `skip_missing=True`
+- Item is logged in `skipped_items` list
+- No error is raised
+
+### Multi-Location Inventory
+
+The service finds the first location with available stock:
+```python
+def _find_inventory_location(db, product_id, store_id):
+ return (
+ db.query(Inventory)
+ .filter(
+ Inventory.product_id == product_id,
+ Inventory.store_id == store_id,
+ Inventory.quantity > Inventory.reserved_quantity,
+ )
+ .first()
+ )
+```
+
+## Usage Example
+
+### Automatic (Via Order Status Update)
+
+```python
+from app.services.order_service import order_service
+from models.schema.order import OrderUpdate
+
+# Update order status - inventory is handled automatically
+order = order_service.update_order_status(
+ db=db,
+ store_id=store_id,
+ order_id=order_id,
+ order_update=OrderUpdate(status="processing")
+)
+# Inventory is now reserved for this order
+```
+
+### Direct (Manual Operations)
+
+```python
+from app.services.order_inventory_service import order_inventory_service
+
+# Reserve inventory for an order
+result = order_inventory_service.reserve_for_order(
+ db=db,
+ store_id=store_id,
+ order_id=order_id,
+ skip_missing=True
+)
+print(f"Reserved: {result['reserved_count']}, Skipped: {len(result['skipped_items'])}")
+
+# Fulfill when shipped
+result = order_inventory_service.fulfill_order(
+ db=db,
+ store_id=store_id,
+ order_id=order_id
+)
+
+# Release if cancelled
+result = order_inventory_service.release_order_reservation(
+ db=db,
+ store_id=store_id,
+ order_id=order_id
+)
+```
+
+## Inventory Model
+
+```python
+class Inventory:
+ quantity: int # Total stock
+ reserved_quantity: int # Reserved for pending orders
+
+ @property
+ def available_quantity(self):
+ return self.quantity - self.reserved_quantity
+```
+
+## Logging
+
+All inventory operations are logged:
+
+```
+INFO: Reserved 2 units of product 123 for order ORD-1-20260101-ABC123
+INFO: Order ORD-1-20260101-ABC123: reserved 3 items, skipped 1
+INFO: Fulfilled 2 units of product 123 for order ORD-1-20260101-ABC123
+WARNING: Order ORD-1-20260101-ABC123 inventory operation failed: No inventory found
+```
+
+## Audit Trail (Phase 2)
+
+All inventory operations are logged to the `inventory_transactions` table.
+
+### Transaction Types
+
+| Type | 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 |
+
+### Transaction Record
+
+```python
+class InventoryTransaction:
+ id: int
+ store_id: int
+ product_id: int
+ inventory_id: int | None
+ transaction_type: TransactionType
+ quantity_change: int # Positive = add, negative = remove
+ quantity_after: int # Snapshot after transaction
+ reserved_after: int # Snapshot after transaction
+ location: str | None
+ warehouse: str | None
+ order_id: int | None # Link to order if applicable
+ order_number: str | None
+ reason: str | None # Human-readable reason
+ created_by: str | None # User/system identifier
+ created_at: datetime
+```
+
+### Example Transaction Query
+
+```python
+from models.database import InventoryTransaction, TransactionType
+
+# Get all transactions for an order
+transactions = db.query(InventoryTransaction).filter(
+ InventoryTransaction.order_id == order_id
+).order_by(InventoryTransaction.created_at).all()
+
+# Get recent stock changes for a product
+recent = db.query(InventoryTransaction).filter(
+ InventoryTransaction.product_id == product_id,
+ InventoryTransaction.store_id == store_id,
+).order_by(InventoryTransaction.created_at.desc()).limit(10).all()
+```
+
+## Partial Shipments (Phase 3)
+
+Orders can be partially shipped, allowing stores to ship items as they become available.
+
+### Status Flow
+
+```
+pending → processing → partially_shipped → shipped → delivered
+ ↘ ↗
+ → shipped (if all items shipped at once)
+```
+
+### OrderItem Tracking
+
+Each order item has a `shipped_quantity` field:
+
+```python
+class OrderItem:
+ quantity: int # Total ordered
+ shipped_quantity: int # Units shipped so far
+
+ @property
+ def remaining_quantity(self):
+ return self.quantity - self.shipped_quantity
+
+ @property
+ def is_fully_shipped(self):
+ return self.shipped_quantity >= self.quantity
+```
+
+### API Endpoints
+
+#### Get Shipment Status
+
+```http
+GET /api/v1/store/orders/{order_id}/shipment-status
+```
+
+Returns item-level shipment status:
+```json
+{
+ "order_id": 123,
+ "order_number": "ORD-1-20260101-ABC123",
+ "order_status": "partially_shipped",
+ "is_fully_shipped": false,
+ "is_partially_shipped": true,
+ "shipped_item_count": 1,
+ "total_item_count": 3,
+ "total_shipped_units": 2,
+ "total_ordered_units": 5,
+ "items": [
+ {
+ "item_id": 1,
+ "product_name": "Widget A",
+ "quantity": 2,
+ "shipped_quantity": 2,
+ "remaining_quantity": 0,
+ "is_fully_shipped": true
+ },
+ {
+ "item_id": 2,
+ "product_name": "Widget B",
+ "quantity": 3,
+ "shipped_quantity": 0,
+ "remaining_quantity": 3,
+ "is_fully_shipped": false
+ }
+ ]
+}
+```
+
+#### Ship Individual Item
+
+```http
+POST /api/v1/store/orders/{order_id}/items/{item_id}/ship
+Content-Type: application/json
+
+{
+ "quantity": 2 // Optional - defaults to remaining quantity
+}
+```
+
+Response:
+```json
+{
+ "order_id": 123,
+ "item_id": 1,
+ "fulfilled_quantity": 2,
+ "shipped_quantity": 2,
+ "remaining_quantity": 0,
+ "is_fully_shipped": true
+}
+```
+
+### Automatic Status Updates
+
+When shipping items:
+1. If some items are shipped → status becomes `partially_shipped`
+2. If all items are fully shipped → status becomes `shipped`
+
+### Service Usage
+
+```python
+from app.services.order_inventory_service import order_inventory_service
+
+# Ship partial quantity of an item
+result = order_inventory_service.fulfill_item(
+ db=db,
+ store_id=store_id,
+ order_id=order_id,
+ item_id=item_id,
+ quantity=2, # Ship 2 units
+)
+
+# Get shipment status
+status = order_inventory_service.get_shipment_status(
+ db=db,
+ store_id=store_id,
+ order_id=order_id,
+)
+```
+
+## Future Enhancements
+
+1. **Multi-Location Selection** - Choose which location to draw from
+2. **Backorder Support** - Handle orders when stock is insufficient
+3. **Return Processing** - Increase stock when orders are returned
diff --git a/app/modules/orders/docs/unified-order-view.md b/app/modules/orders/docs/unified-order-view.md
new file mode 100644
index 00000000..6d76ae08
--- /dev/null
+++ b/app/modules/orders/docs/unified-order-view.md
@@ -0,0 +1,275 @@
+# Unified Order Schema Implementation
+
+## Overview
+
+This document describes the unified order schema that consolidates all order types (direct and marketplace) into a single `orders` table with snapshotted customer and address data.
+
+## Design Decision: Option B - Single Unified Table
+
+After analyzing the gap between internal orders and Letzshop orders, we chose **Option B: Full Import to Order Table** with the following key principles:
+
+1. **Single `orders` table** for all channels (direct, letzshop, future marketplaces)
+2. **Customer/address snapshots** preserved at order time (not just FK references)
+3. **Products must exist** in catalog - GTIN lookup errors trigger investigation
+4. **Inactive customers** created for marketplace imports until they register on storefront
+5. **No separate `letzshop_orders` table** - eliminates sync issues
+
+## Schema Design
+
+### Order Table
+
+The `orders` table now includes:
+
+```
+orders
+├── Identity
+│ ├── id (PK)
+│ ├── store_id (FK → stores)
+│ ├── customer_id (FK → customers)
+│ └── order_number (unique)
+│
+├── Channel/Source
+│ ├── channel (direct | letzshop)
+│ ├── external_order_id
+│ ├── external_shipment_id
+│ ├── external_order_number
+│ └── external_data (JSON - raw marketplace data)
+│
+├── Status
+│ └── status (pending | processing | shipped | delivered | cancelled | refunded)
+│
+├── Financials
+│ ├── subtotal (nullable for marketplace)
+│ ├── tax_amount
+│ ├── shipping_amount
+│ ├── discount_amount
+│ ├── total_amount
+│ └── currency
+│
+├── Customer Snapshot
+│ ├── customer_first_name
+│ ├── customer_last_name
+│ ├── customer_email
+│ ├── customer_phone
+│ └── customer_locale
+│
+├── Shipping Address Snapshot
+│ ├── ship_first_name
+│ ├── ship_last_name
+│ ├── ship_company
+│ ├── ship_address_line_1
+│ ├── ship_address_line_2
+│ ├── ship_city
+│ ├── ship_postal_code
+│ └── ship_country_iso
+│
+├── Billing Address Snapshot
+│ ├── bill_first_name
+│ ├── bill_last_name
+│ ├── bill_company
+│ ├── bill_address_line_1
+│ ├── bill_address_line_2
+│ ├── bill_city
+│ ├── bill_postal_code
+│ └── bill_country_iso
+│
+├── Tracking
+│ ├── shipping_method
+│ ├── tracking_number
+│ └── tracking_provider
+│
+├── Notes
+│ ├── customer_notes
+│ └── internal_notes
+│
+└── Timestamps
+ ├── order_date (when customer placed order)
+ ├── confirmed_at
+ ├── shipped_at
+ ├── delivered_at
+ ├── cancelled_at
+ ├── created_at
+ └── updated_at
+```
+
+### OrderItem Table
+
+The `order_items` table includes:
+
+```
+order_items
+├── Identity
+│ ├── id (PK)
+│ ├── order_id (FK → orders)
+│ └── product_id (FK → products, NOT NULL)
+│
+├── Product Snapshot
+│ ├── product_name
+│ ├── product_sku
+│ ├── gtin
+│ └── gtin_type (ean13, upc, isbn, etc.)
+│
+├── Pricing
+│ ├── quantity
+│ ├── unit_price
+│ └── total_price
+│
+├── External References
+│ ├── external_item_id (Letzshop inventory unit ID)
+│ └── external_variant_id
+│
+├── Item State (marketplace confirmation)
+│ └── item_state (confirmed_available | confirmed_unavailable)
+│
+└── Inventory
+ ├── inventory_reserved
+ └── inventory_fulfilled
+```
+
+## Status Mapping
+
+| Letzshop State | Order Status | Description |
+|----------------|--------------|-------------|
+| `unconfirmed` | `pending` | Order received, awaiting confirmation |
+| `confirmed` | `processing` | Items confirmed, being prepared |
+| `confirmed` + tracking | `shipped` | Shipped with tracking info |
+| `declined` | `cancelled` | All items declined |
+
+## Customer Handling
+
+When importing marketplace orders:
+
+1. Look up customer by `(store_id, email)`
+2. If not found, create with `is_active=False`
+3. Customer becomes active when they register on storefront
+4. Customer info is always snapshotted in order (regardless of customer record)
+
+This ensures:
+- Customer history is preserved even if customer info changes
+- Marketplace customers can later claim their order history
+- No data loss if customer record is modified
+
+## Shipping Workflows
+
+### Scenario 1: Letzshop Auto-Shipping
+
+When using Letzshop's shipping service:
+
+1. Order confirmed → `status = processing`
+2. Letzshop auto-creates shipment with their carrier
+3. Operator picks & packs
+4. Operator clicks "Retrieve Shipping Info"
+5. App fetches tracking from Letzshop API
+6. Order updated → `status = shipped`
+
+### Scenario 2: Store Own Shipping
+
+When store uses their own carrier:
+
+1. Order confirmed → `status = processing`
+2. Operator picks & packs with own carrier
+3. Operator enters tracking info in app
+4. App sends tracking to Letzshop API
+5. Order updated → `status = shipped`
+
+## Removed: LetzshopOrder Table
+
+The `letzshop_orders` table has been removed. All data now goes directly into the unified `orders` table with `channel = 'letzshop'`.
+
+### Migration of Existing References
+
+- `LetzshopFulfillmentQueue.letzshop_order_id` → `order_id` (FK to `orders`)
+- `LetzshopSyncLog` - unchanged (no order reference)
+- `LetzshopHistoricalImportJob` - unchanged (no order reference)
+
+## Files Modified
+
+| File | Changes |
+|------|---------|
+| `models/database/order.py` | Complete rewrite with snapshots |
+| `models/database/letzshop.py` | Removed `LetzshopOrder`, updated `LetzshopFulfillmentQueue` |
+| `models/schema/order.py` | Updated schemas for new structure |
+| `models/schema/letzshop.py` | Updated schemas for unified Order model |
+| `app/services/order_service.py` | Unified service with `create_letzshop_order()` |
+| `app/services/letzshop/order_service.py` | Updated to use unified Order model |
+| `app/api/v1/admin/letzshop.py` | Updated endpoints for unified model |
+| `alembic/versions/c1d2e3f4a5b6_unified_order_schema.py` | Migration |
+
+## API Endpoints
+
+All Letzshop order endpoints now use the unified Order model:
+
+| Endpoint | Description |
+|----------|-------------|
+| `GET /admin/letzshop/stores/{id}/orders` | List orders with `channel='letzshop'` filter |
+| `GET /admin/letzshop/orders/{id}` | Get order detail with items |
+| `POST /admin/letzshop/stores/{id}/orders/{id}/confirm` | Confirm items via `external_item_id` |
+| `POST /admin/letzshop/stores/{id}/orders/{id}/reject` | Decline items via `external_item_id` |
+| `POST /admin/letzshop/stores/{id}/orders/{id}/items/{item_id}/confirm` | Confirm single item |
+| `POST /admin/letzshop/stores/{id}/orders/{id}/items/{item_id}/decline` | Decline single item |
+
+## Order Number Format
+
+| Channel | Format | Example |
+|---------|--------|---------|
+| Direct | `ORD-{store_id}-{date}-{random}` | `ORD-1-20251219-A1B2C3` |
+| Letzshop | `LS-{store_id}-{letzshop_order_number}` | `LS-1-ORD-123456` |
+
+## Error Handling
+
+### Product Not Found by GTIN
+
+When importing a Letzshop order, if a product cannot be found by its GTIN:
+
+```python
+raise ValidationException(
+ f"Product not found for GTIN {gtin}. "
+ f"Please ensure the product catalog is in sync."
+)
+```
+
+This is intentional - the Letzshop catalog is sourced from the store catalog, so missing products indicate a sync issue that must be investigated.
+
+## Future Considerations
+
+### Performance at Scale
+
+As the orders table grows, consider:
+
+1. **Partitioning** by `order_date` or `store_id`
+2. **Archiving** old orders to separate tables
+3. **Read replicas** for reporting queries
+4. **Materialized views** for dashboard statistics
+
+### Additional Marketplaces
+
+The schema supports additional channels:
+
+```python
+channel = Column(String(50)) # direct, letzshop, amazon, ebay, etc.
+```
+
+Each marketplace would use:
+- `external_order_id` - Marketplace order ID
+- `external_shipment_id` - Marketplace shipment ID
+- `external_order_number` - Display order number
+- `external_data` - Raw marketplace data (JSON)
+
+## Implementation Status
+
+- [x] Order model with snapshots
+- [x] OrderItem model with GTIN fields
+- [x] LetzshopFulfillmentQueue updated
+- [x] LetzshopOrder removed
+- [x] Database migration created
+- [x] Order schemas updated
+- [x] Unified order service created
+- [x] Letzshop order service updated
+- [x] Letzshop schemas updated
+- [x] API endpoints updated
+- [x] Frontend updated
+ - [x] Orders tab template (status badges, filters, table)
+ - [x] Order detail page (snapshots, items, tracking)
+ - [x] JavaScript (API params, response handling)
+ - [x] Tracking modal (tracking_provider field)
+ - [x] Order items modal (items array, item_state)
diff --git a/app/modules/orders/docs/vat-invoicing.md b/app/modules/orders/docs/vat-invoicing.md
new file mode 100644
index 00000000..c2382d26
--- /dev/null
+++ b/app/modules/orders/docs/vat-invoicing.md
@@ -0,0 +1,734 @@
+# VAT Invoice Feature - Technical Specification
+
+## Overview
+
+Generate compliant PDF invoices with correct VAT calculation based on destination country, handling EU cross-border VAT rules including OSS (One-Stop-Shop) regime.
+
+---
+
+## EU VAT Rules Summary
+
+### Standard VAT Rates by Country (2024)
+
+| Country | Code | Standard Rate | Reduced |
+|---------|------|---------------|---------|
+| Luxembourg | LU | 17% | 8%, 3% |
+| Germany | DE | 19% | 7% |
+| France | FR | 20% | 10%, 5.5% |
+| Belgium | BE | 21% | 12%, 6% |
+| Netherlands | NL | 21% | 9% |
+| Austria | AT | 20% | 13%, 10% |
+| Italy | IT | 22% | 10%, 5%, 4% |
+| Spain | ES | 21% | 10%, 4% |
+| Portugal | PT | 23% | 13%, 6% |
+| Ireland | IE | 23% | 13.5%, 9% |
+| Poland | PL | 23% | 8%, 5% |
+| Czech Republic | CZ | 21% | 15%, 10% |
+| ... | ... | ... | ... |
+
+### When to Apply Which VAT
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ VAT DECISION TREE │
+├─────────────────────────────────────────────────────────────┤
+│ │
+│ Is buyer a business with valid VAT number? │
+│ ├── YES → Reverse charge (0% VAT, buyer accounts for it) │
+│ └── NO → Continue... │
+│ │
+│ Is destination same country as seller? │
+│ ├── YES → Apply domestic VAT (Luxembourg = 17%) │
+│ └── NO → Continue... │
+│ │
+│ Is seller registered for OSS? │
+│ ├── YES → Apply destination country VAT rate │
+│ └── NO → Continue... │
+│ │
+│ Has seller exceeded €10,000 EU threshold? │
+│ ├── YES → Must register OSS, apply destination VAT │
+│ └── NO → Apply origin country VAT (Luxembourg = 17%) │
+│ │
+└─────────────────────────────────────────────────────────────┘
+```
+
+---
+
+## Data Model
+
+### New Tables
+
+```sql
+-- VAT configuration per store
+CREATE TABLE store_vat_settings (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ store_id UUID NOT NULL REFERENCES stores(id),
+
+ -- Merchant details for invoices
+ merchant_name VARCHAR(255) NOT NULL,
+ merchant_address TEXT NOT NULL,
+ merchant_city VARCHAR(100) NOT NULL,
+ merchant_postal_code VARCHAR(20) NOT NULL,
+ merchant_country VARCHAR(2) NOT NULL DEFAULT 'LU',
+ vat_number VARCHAR(50), -- e.g., "LU12345678"
+
+ -- VAT regime
+ is_vat_registered BOOLEAN DEFAULT TRUE,
+ is_oss_registered BOOLEAN DEFAULT FALSE, -- One-Stop-Shop
+
+ -- Invoice numbering
+ invoice_prefix VARCHAR(20) DEFAULT 'INV',
+ invoice_next_number INTEGER DEFAULT 1,
+
+ -- Optional
+ payment_terms TEXT, -- e.g., "Due upon receipt"
+ bank_details TEXT, -- IBAN, etc.
+ footer_text TEXT, -- Legal text, thank you message
+
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+-- EU VAT rates reference table
+CREATE TABLE eu_vat_rates (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ country_code VARCHAR(2) NOT NULL, -- ISO 3166-1 alpha-2
+ country_name VARCHAR(100) NOT NULL,
+ standard_rate DECIMAL(5,2) NOT NULL, -- e.g., 17.00
+ reduced_rate_1 DECIMAL(5,2),
+ reduced_rate_2 DECIMAL(5,2),
+ super_reduced_rate DECIMAL(5,2),
+ effective_from DATE NOT NULL,
+ effective_until DATE, -- NULL = current
+
+ UNIQUE(country_code, effective_from)
+);
+
+-- Generated invoices
+CREATE TABLE invoices (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ store_id UUID NOT NULL REFERENCES stores(id),
+ order_id UUID REFERENCES orders(id), -- Can be NULL for manual invoices
+
+ -- Invoice identity
+ invoice_number VARCHAR(50) NOT NULL, -- e.g., "INV-2024-0042"
+ invoice_date DATE NOT NULL DEFAULT CURRENT_DATE,
+
+ -- Parties
+ seller_details JSONB NOT NULL, -- Snapshot of store at invoice time
+ buyer_details JSONB NOT NULL, -- Snapshot of customer at invoice time
+
+ -- VAT calculation details
+ destination_country VARCHAR(2) NOT NULL,
+ vat_regime VARCHAR(20) NOT NULL, -- 'domestic', 'oss', 'reverse_charge', 'origin'
+ vat_rate DECIMAL(5,2) NOT NULL,
+
+ -- Amounts
+ subtotal_net DECIMAL(12,2) NOT NULL, -- Before VAT
+ vat_amount DECIMAL(12,2) NOT NULL,
+ total_gross DECIMAL(12,2) NOT NULL, -- After VAT
+ currency VARCHAR(3) DEFAULT 'EUR',
+
+ -- Line items snapshot
+ line_items JSONB NOT NULL,
+
+ -- PDF storage
+ pdf_path VARCHAR(500), -- Path to generated PDF
+ pdf_generated_at TIMESTAMP,
+
+ -- Status
+ status VARCHAR(20) DEFAULT 'draft', -- draft, issued, paid, cancelled
+
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+
+ UNIQUE(store_id, invoice_number)
+);
+```
+
+### Line Items JSONB Structure
+
+```json
+{
+ "items": [
+ {
+ "description": "Product Name",
+ "sku": "ABC123",
+ "quantity": 2,
+ "unit_price_net": 25.00,
+ "vat_rate": 17.00,
+ "vat_amount": 8.50,
+ "line_total_net": 50.00,
+ "line_total_gross": 58.50
+ }
+ ]
+}
+```
+
+---
+
+## Service Layer
+
+### VATService
+
+```python
+# app/services/vat_service.py
+
+from decimal import Decimal
+from datetime import date
+from typing import Optional
+
+class VATService:
+ """Handles VAT calculation logic for EU cross-border sales."""
+
+ # Fallback rates if DB lookup fails
+ DEFAULT_RATES = {
+ 'LU': Decimal('17.00'),
+ 'DE': Decimal('19.00'),
+ 'FR': Decimal('20.00'),
+ 'BE': Decimal('21.00'),
+ 'NL': Decimal('21.00'),
+ 'AT': Decimal('20.00'),
+ 'IT': Decimal('22.00'),
+ 'ES': Decimal('21.00'),
+ # ... etc
+ }
+
+ def __init__(self, db_session):
+ self.db = db_session
+
+ def get_vat_rate(self, country_code: str, as_of: date = None) -> Decimal:
+ """Get current VAT rate for a country."""
+ as_of = as_of or date.today()
+
+ # Try DB first
+ rate = self.db.query(EUVATRate).filter(
+ EUVATRate.country_code == country_code,
+ EUVATRate.effective_from <= as_of,
+ (EUVATRate.effective_until.is_(None) | (EUVATRate.effective_until >= as_of))
+ ).first()
+
+ if rate:
+ return rate.standard_rate
+
+ # Fallback
+ return self.DEFAULT_RATES.get(country_code, Decimal('0.00'))
+
+ def determine_vat_regime(
+ self,
+ seller_country: str,
+ buyer_country: str,
+ buyer_vat_number: Optional[str],
+ seller_is_oss: bool,
+ seller_exceeded_threshold: bool = False
+ ) -> tuple[str, Decimal]:
+ """
+ Determine which VAT regime applies and the rate.
+
+ Returns: (regime_name, vat_rate)
+ """
+ # B2B with valid VAT number = reverse charge
+ if buyer_vat_number and self._validate_vat_number(buyer_vat_number):
+ return ('reverse_charge', Decimal('0.00'))
+
+ # Domestic sale
+ if seller_country == buyer_country:
+ return ('domestic', self.get_vat_rate(seller_country))
+
+ # Cross-border B2C
+ if seller_is_oss:
+ # OSS: destination country VAT
+ return ('oss', self.get_vat_rate(buyer_country))
+
+ if seller_exceeded_threshold:
+ # Should be OSS but isn't - use destination anyway (compliance issue)
+ return ('oss_required', self.get_vat_rate(buyer_country))
+
+ # Under threshold: origin country VAT
+ return ('origin', self.get_vat_rate(seller_country))
+
+ def _validate_vat_number(self, vat_number: str) -> bool:
+ """
+ Validate EU VAT number format.
+ For production: integrate with VIES API.
+ """
+ if not vat_number or len(vat_number) < 4:
+ return False
+
+ # Basic format check: 2-letter country + numbers
+ country = vat_number[:2].upper()
+ return country in self.DEFAULT_RATES
+
+ def calculate_invoice_totals(
+ self,
+ line_items: list[dict],
+ vat_rate: Decimal
+ ) -> dict:
+ """Calculate invoice totals with VAT."""
+ subtotal_net = Decimal('0.00')
+
+ for item in line_items:
+ quantity = Decimal(str(item['quantity']))
+ unit_price = Decimal(str(item['unit_price_net']))
+ line_net = quantity * unit_price
+
+ item['line_total_net'] = float(line_net)
+ item['vat_rate'] = float(vat_rate)
+ item['vat_amount'] = float(line_net * vat_rate / 100)
+ item['line_total_gross'] = float(line_net + line_net * vat_rate / 100)
+
+ subtotal_net += line_net
+
+ vat_amount = subtotal_net * vat_rate / 100
+ total_gross = subtotal_net + vat_amount
+
+ return {
+ 'subtotal_net': float(subtotal_net),
+ 'vat_rate': float(vat_rate),
+ 'vat_amount': float(vat_amount),
+ 'total_gross': float(total_gross),
+ 'line_items': line_items
+ }
+```
+
+### InvoiceService
+
+```python
+# app/services/invoice_service.py
+
+class InvoiceService:
+ """Generate and manage invoices."""
+
+ def __init__(self, db_session, vat_service: VATService):
+ self.db = db_session
+ self.vat = vat_service
+
+ def create_invoice_from_order(
+ self,
+ order_id: UUID,
+ store_id: UUID
+ ) -> Invoice:
+ """Generate invoice from an existing order."""
+ order = self.db.query(Order).get(order_id)
+ vat_settings = self.db.query(StoreVATSettings).filter_by(
+ store_id=store_id
+ ).first()
+
+ if not vat_settings:
+ raise ValueError("Store VAT settings not configured")
+
+ # Determine VAT regime
+ regime, rate = self.vat.determine_vat_regime(
+ seller_country=vat_settings.merchant_country,
+ buyer_country=order.shipping_country,
+ buyer_vat_number=order.customer_vat_number,
+ seller_is_oss=vat_settings.is_oss_registered
+ )
+
+ # Prepare line items
+ line_items = [
+ {
+ 'description': item.product_name,
+ 'sku': item.sku,
+ 'quantity': item.quantity,
+ 'unit_price_net': float(item.unit_price)
+ }
+ for item in order.items
+ ]
+
+ # Calculate totals
+ totals = self.vat.calculate_invoice_totals(line_items, rate)
+
+ # Generate invoice number
+ invoice_number = self._generate_invoice_number(vat_settings)
+
+ # Create invoice
+ invoice = Invoice(
+ store_id=store_id,
+ order_id=order_id,
+ invoice_number=invoice_number,
+ invoice_date=date.today(),
+ seller_details=self._snapshot_seller(vat_settings),
+ buyer_details=self._snapshot_buyer(order),
+ destination_country=order.shipping_country,
+ vat_regime=regime,
+ vat_rate=rate,
+ subtotal_net=totals['subtotal_net'],
+ vat_amount=totals['vat_amount'],
+ total_gross=totals['total_gross'],
+ line_items={'items': totals['line_items']},
+ status='issued'
+ )
+
+ self.db.add(invoice)
+ self.db.commit()
+
+ return invoice
+
+ def _generate_invoice_number(self, settings: StoreVATSettings) -> str:
+ """Generate next invoice number and increment counter."""
+ year = date.today().year
+ number = settings.invoice_next_number
+
+ invoice_number = f"{settings.invoice_prefix}-{year}-{number:04d}"
+
+ settings.invoice_next_number += 1
+ self.db.commit()
+
+ return invoice_number
+
+ def _snapshot_seller(self, settings: StoreVATSettings) -> dict:
+ """Capture seller details at invoice time."""
+ return {
+ 'merchant_name': settings.merchant_name,
+ 'address': settings.merchant_address,
+ 'city': settings.merchant_city,
+ 'postal_code': settings.merchant_postal_code,
+ 'country': settings.merchant_country,
+ 'vat_number': settings.vat_number
+ }
+
+ def _snapshot_buyer(self, order: Order) -> dict:
+ """Capture buyer details at invoice time."""
+ return {
+ 'name': f"{order.shipping_first_name} {order.shipping_last_name}",
+ 'merchant': order.shipping_merchant,
+ 'address': order.shipping_address,
+ 'city': order.shipping_city,
+ 'postal_code': order.shipping_postal_code,
+ 'country': order.shipping_country,
+ 'vat_number': order.customer_vat_number
+ }
+```
+
+---
+
+## PDF Generation
+
+### Using WeasyPrint
+
+```python
+# app/services/invoice_pdf_service.py
+
+from weasyprint import HTML, CSS
+from jinja2 import Environment, FileSystemLoader
+
+class InvoicePDFService:
+ """Generate PDF invoices."""
+
+ def __init__(self, template_dir: str = 'app/templates/invoices'):
+ self.env = Environment(loader=FileSystemLoader(template_dir))
+
+ def generate_pdf(self, invoice: Invoice) -> bytes:
+ """Generate PDF bytes from invoice."""
+ template = self.env.get_template('invoice.html')
+
+ html_content = template.render(
+ invoice=invoice,
+ seller=invoice.seller_details,
+ buyer=invoice.buyer_details,
+ items=invoice.line_items['items'],
+ vat_label=self._get_vat_label(invoice.vat_regime)
+ )
+
+ pdf_bytes = HTML(string=html_content).write_pdf(
+ stylesheets=[CSS(filename='app/static/css/invoice.css')]
+ )
+
+ return pdf_bytes
+
+ def _get_vat_label(self, regime: str) -> str:
+ """Human-readable VAT regime label."""
+ labels = {
+ 'domestic': 'TVA Luxembourg',
+ 'oss': 'TVA (OSS - pays de destination)',
+ 'reverse_charge': 'Autoliquidation (Reverse Charge)',
+ 'origin': 'TVA pays d\'origine'
+ }
+ return labels.get(regime, 'TVA')
+```
+
+### Invoice HTML Template
+
+```html
+
+
+
+