docs: migrate module documentation to single source of truth

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

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

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

View File

@@ -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.

View File

@@ -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

View File

@@ -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") %}
<div>Analytics content here - only visible if feature available</div>
{% 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

View File

@@ -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

View File

@@ -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"<StorePaymentConfig(store_id={self.store_id}, stripe_account_id='{self.stripe_account_id}')>"
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"<Payment(id={self.id}, order_id={self.order_id}, status='{self.status}')>"
@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"<PaymentMethod(id={self.id}, customer_id={self.customer_id}, type='{self.payment_method_type}')>"
```
### 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.

View File

@@ -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

View File

@@ -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?

View File

@@ -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

View File

@@ -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.

View File

@@ -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`

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

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

View File

@@ -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

View File

@@ -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.

View File

@@ -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="<html>...</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="<html>...</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

View File

@@ -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 %}
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{# Breadcrumbs #}
<nav class="mb-6 text-sm">
<a href="{{ base_url }}" class="text-primary hover:underline">Home</a>
<span class="mx-2 text-gray-400">/</span>
<span class="text-gray-600 dark:text-gray-300">{{ page.title }}</span>
</nav>
{# Page Title #}
<h1 class="text-4xl font-bold text-gray-900 dark:text-gray-100 mb-8">
{{ page.title }}
</h1>
{# Content #}
<div class="prose prose-lg dark:prose-invert max-w-none">
{{ page.content | safe }}
</div>
{# Last updated #}
{% if page.updated_at %}
<div class="mt-12 pt-6 border-t border-gray-200 dark:border-gray-700 text-sm text-gray-500 dark:text-gray-400">
Last updated: {{ page.updated_at.strftime('%B %d, %Y') }}
</div>
{% endif %}
</div>
{% endblock %}
```
### 6. Update Footer to Load Navigation Dynamically
Edit `app/templates/storefront/base.html` to load navigation from database.
First, update the context helper to include footer pages:
```python
# app/routes/storefront_pages.py
def get_storefront_context(request: Request, **extra_context) -> dict:
# ... existing code ...
# Load footer navigation pages
db = next(get_db())
try:
footer_pages = content_page_service.list_pages_for_store(
db,
store_id=store.id if store else None,
include_unpublished=False,
footer_only=True
)
finally:
db.close()
context = {
"request": request,
"store": store,
"theme": theme,
"clean_path": clean_path,
"access_method": access_method,
"base_url": base_url,
"footer_pages": footer_pages, # Add this
**extra_context
}
return context
```
Then update the footer template:
```jinja2
{# app/templates/storefront/base.html - Footer section #}
<div>
<h4 class="font-semibold mb-4">Quick Links</h4>
<ul class="space-y-2">
{% for page in footer_pages %}
<li>
<a href="{{ base_url }}{{ page.slug }}"
class="text-gray-600 hover:text-primary dark:text-gray-400">
{{ page.title }}
</a>
</li>
{% endfor %}
</ul>
</div>
```
### 7. Create Default Platform Pages (Script)
Create `scripts/seed/create_default_content_pages.py`:
```python
#!/usr/bin/env python3
"""Create default platform content pages."""
from sqlalchemy.orm import Session
from app.core.database import SessionLocal
from app.services.content_page_service import content_page_service
def create_defaults():
db: Session = SessionLocal()
try:
# About Us
content_page_service.create_page(
db,
slug="about",
title="About Us",
content="""
<h2>Welcome to Our Marketplace</h2>
<p>We connect quality stores with customers worldwide.</p>
<p>Our mission is to provide a seamless shopping experience...</p>
""",
is_published=True,
show_in_footer=True,
display_order=1
)
# Shipping Information
content_page_service.create_page(
db,
slug="shipping",
title="Shipping Information",
content="""
<h2>Shipping Policy</h2>
<p>We offer fast and reliable shipping...</p>
""",
is_published=True,
show_in_footer=True,
display_order=2
)
# Returns
content_page_service.create_page(
db,
slug="returns",
title="Returns & Refunds",
content="""
<h2>Return Policy</h2>
<p>30-day return policy on all items...</p>
""",
is_published=True,
show_in_footer=True,
display_order=3
)
# Privacy Policy
content_page_service.create_page(
db,
slug="privacy",
title="Privacy Policy",
content="""
<h2>Privacy Policy</h2>
<p>Your privacy is important to us...</p>
""",
is_published=True,
show_in_footer=True,
display_order=4
)
# Terms of Service
content_page_service.create_page(
db,
slug="terms",
title="Terms of Service",
content="""
<h2>Terms of Service</h2>
<p>By using our platform, you agree to...</p>
""",
is_published=True,
show_in_footer=True,
display_order=5
)
# Contact
content_page_service.create_page(
db,
slug="contact",
title="Contact Us",
content="""
<h2>Get in Touch</h2>
<p>Have questions? We'd love to hear from you!</p>
<p>Email: support@example.com</p>
""",
is_published=True,
show_in_footer=True,
display_order=6
)
# FAQ
content_page_service.create_page(
db,
slug="faq",
title="Frequently Asked Questions",
content="""
<h2>FAQ</h2>
<h3>How do I place an order?</h3>
<p>Simply browse our products...</p>
""",
is_published=True,
show_in_footer=True,
display_order=7
)
print("✅ Created default content pages successfully")
except Exception as e:
print(f"❌ Error: {e}")
db.rollback()
finally:
db.close()
if __name__ == "__main__":
create_defaults()
```
Run it:
```bash
python scripts/seed/create_default_content_pages.py
```
## Testing
### 1. Test Platform Defaults
```bash
# Create platform default
curl -X POST http://localhost:8000/api/v1/admin/content-pages/platform \
-H "Authorization: Bearer <admin_token>" \
-H "Content-Type: application/json" \
-d '{
"slug": "about",
"title": "About Our Marketplace",
"content": "<h1>About</h1><p>Platform default content</p>",
"is_published": true,
"show_in_footer": true
}'
# View in storefront
curl http://localhost:8000/store/orion/about
```
### 2. Test Store Override
```bash
# Create store override
curl -X POST http://localhost:8000/api/v1/store/orion/content-pages/ \
-H "Authorization: Bearer <store_token>" \
-H "Content-Type: application/json" \
-d '{
"slug": "about",
"title": "About Orion",
"content": "<h1>About Orion</h1><p>Custom store content</p>",
"is_published": true
}'
# View in storefront (should show store content)
curl http://localhost:8000/store/orion/about
```
### 3. Test Fallback
```bash
# Delete store override
curl -X DELETE http://localhost:8000/api/v1/store/orion/content-pages/{id} \
-H "Authorization: Bearer <store_token>"
# View in storefront (should fall back to platform default)
curl http://localhost:8000/store/orion/about
```
## Summary
You now have a complete CMS system that allows:
1. **Platform admins** to create default content for all stores
2. **Stores** to override specific pages with custom content
3. **Automatic fallback** to platform defaults when store hasn't customized
4. **Dynamic navigation** loading from database
5. **SEO optimization** with meta tags
6. **Draft/Published workflow** for content management
All pages are accessible via their slug: `/about`, `/faq`, `/contact`, etc. with proper store context and routing support!

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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.

View File

@@ -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)`.

View File

@@ -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

View File

@@ -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.*

View File

@@ -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*

View File

@@ -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.

View File

@@ -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

View File

@@ -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 <store_token>
```
**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 <admin_token>
```
**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.

File diff suppressed because it is too large Load Diff

View File

@@ -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 |

View File

@@ -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 |

View File

@@ -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

Binary file not shown.

View File

@@ -0,0 +1,716 @@
# Letzshop Jobs & Tables Improvements
Implementation plan for improving the Letzshop management page jobs display and table harmonization.
## Status: Completed
### Completed
- [x] Phase 1: Job Details Modal (commit cef80af)
- [x] Phase 2: Add store column to jobs table
- [x] Phase 3: Platform settings system (rows per page)
- [x] Phase 4: Numbered pagination for jobs table
- [x] Phase 5: Admin customer management page
---
## Overview
This plan addresses 6 improvements:
1. Job details modal with proper display
2. Tab visibility fix when filters cleared
3. Add store column to jobs table
4. Harmonize all tables with table macro
5. Platform-wide rows per page setting
6. Build admin customer page
---
## 1. Job Details Modal
### Current Issue
- "View Details" shows a browser alert instead of a proper modal
- No detailed breakdown of export results
### Requirements
- Create a proper modal for job details
- For exports: show products exported per language file
- Show store name/code
- Show full timestamps and duration
- Show error details if any
### Implementation
#### 1.1 Create Job Details Modal Template
**File:** `app/templates/admin/partials/letzshop-jobs-table.html`
Add modal after the table:
```html
<!-- Job Details Modal -->
<div
x-show="showJobDetailsModal"
x-transition
class="fixed inset-0 z-30 flex items-center justify-center bg-black bg-opacity-50"
@click.self="showJobDetailsModal = false"
x-cloak
>
<div class="w-full max-w-lg bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6">
<header class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold">Job Details</h3>
<button @click="showJobDetailsModal = false">×</button>
</header>
<div class="space-y-4">
<!-- Job Info -->
<div class="grid grid-cols-2 gap-4 text-sm">
<div><span class="font-medium">Job ID:</span> #<span x-text="selectedJobDetails?.id"></span></div>
<div><span class="font-medium">Type:</span> <span x-text="selectedJobDetails?.type"></span></div>
<div><span class="font-medium">Status:</span> <span x-text="selectedJobDetails?.status"></span></div>
<div><span class="font-medium">Store:</span> <span x-text="selectedJobDetails?.store_name || selectedStore?.name"></span></div>
</div>
<!-- Timestamps -->
<div class="text-sm">
<p><span class="font-medium">Started:</span> <span x-text="formatDate(selectedJobDetails?.started_at)"></span></p>
<p><span class="font-medium">Completed:</span> <span x-text="formatDate(selectedJobDetails?.completed_at)"></span></p>
<p><span class="font-medium">Duration:</span> <span x-text="formatDuration(selectedJobDetails?.started_at, selectedJobDetails?.completed_at)"></span></p>
</div>
<!-- Export Details (for export jobs) -->
<template x-if="selectedJobDetails?.type === 'export' && selectedJobDetails?.error_details?.products_exported">
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-3">
<h4 class="font-medium mb-2">Export Details</h4>
<p class="text-sm">Products exported: <span x-text="selectedJobDetails.error_details.products_exported"></span></p>
<template x-if="selectedJobDetails.error_details.files">
<div class="mt-2 space-y-1">
<template x-for="file in selectedJobDetails.error_details.files" :key="file.language">
<div class="text-xs flex justify-between">
<span x-text="file.language.toUpperCase()"></span>
<span x-text="file.error ? 'Failed: ' + file.error : file.filename + ' (' + (file.size_bytes / 1024).toFixed(1) + ' KB)'"></span>
</div>
</template>
</div>
</template>
</div>
</template>
<!-- Error Details -->
<template x-if="selectedJobDetails?.error_message || selectedJobDetails?.error_details?.error">
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
<h4 class="font-medium text-red-700 mb-2">Error</h4>
<p class="text-sm text-red-600" x-text="selectedJobDetails?.error_message || selectedJobDetails?.error_details?.error"></p>
</div>
</template>
</div>
</div>
</div>
```
#### 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 `<template x-if="selectedStore">`
- 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
<th class="px-4 py-3">Store</th>
```
Add column data:
```html
<td class="px-4 py-3 text-sm">
<span x-text="job.store_code || job.store_name || '-'"></span>
</td>
```
#### 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) %}
<div class="flex items-center justify-between mt-4">
<div class="text-sm text-gray-600">
Showing <span x-text="(({{ page_var }} - 1) * {{ limit_var }}) + 1"></span>
to <span x-text="Math.min({{ page_var }} * {{ limit_var }}, {{ total_var }})"></span>
of <span x-text="{{ total_var }}"></span>
</div>
<div class="flex items-center gap-1">
<!-- First -->
<button @click="{{ page_var }} = 1; {{ on_change }}" :disabled="{{ page_var }} <= 1">«</button>
<!-- Prev -->
<button @click="{{ page_var }}--; {{ on_change }}" :disabled="{{ page_var }} <= 1"></button>
<!-- Page numbers -->
<template x-for="p in getPageNumbers({{ page_var }}, Math.ceil({{ total_var }} / {{ limit_var }}))">
<button @click="{{ page_var }} = p; {{ on_change }}" :class="p === {{ page_var }} ? 'bg-purple-600 text-white' : ''">
<span x-text="p"></span>
</button>
</template>
<!-- Next -->
<button @click="{{ page_var }}++; {{ on_change }}" :disabled="{{ page_var }} * {{ limit_var }} >= {{ total_var }}"></button>
<!-- Last -->
<button @click="{{ page_var }} = Math.ceil({{ total_var }} / {{ limit_var }}); {{ on_change }}" :disabled="{{ page_var }} * {{ limit_var }} >= {{ total_var }}">»</button>
</div>
</div>
{% 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*

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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 = """
<div style="margin-top: 30px; ...">
<p>Powered by <a href="https://orion.lu">Orion</a></p>
</div>
"""
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("</body>", f"{POWERED_BY_FOOTER_HTML}</body>")
```
## 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
<!-- app/templates/shared/macros/feature_gate.html -->
{% macro email_settings_warning() %}
<div x-data="emailSettingsWarning()" x-show="showWarning">
Configure email settings to send emails to customers.
</div>
{% 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

View File

@@ -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

View File

@@ -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="<h1>Hello</h1><p>Custom message</p>",
body_text="Hello\n\nCustom message",
)
```
## Email Templates
### Creating Templates
Templates use Jinja2 syntax for variable interpolation:
```html
<p>Hello {{ first_name }},</p>
<p>Welcome to {{ merchant_name }}!</p>
```
### Seeding Templates
Run the seed script to populate default templates:
```bash
python scripts/seed/seed_email_templates.py
```
This creates templates for:
- `signup_welcome` (en, fr, de, lb)
### Available Variables
For `signup_welcome`:
| Variable | Description |
|----------|-------------|
| first_name | User's first name |
| merchant_name | Store merchant name |
| email | User's email address |
| store_code | Store code for dashboard URL |
| login_url | Direct link to dashboard |
| trial_days | Number of trial days |
| tier_name | Subscription tier name |
## Provider Setup
### SMTP
Standard SMTP configuration:
```env
EMAIL_PROVIDER=smtp
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASSWORD=your-app-password
SMTP_USE_TLS=true
```
### SendGrid
1. Create account at [sendgrid.com](https://sendgrid.com)
2. Generate API key in Settings > API Keys
3. Configure:
```env
EMAIL_PROVIDER=sendgrid
SENDGRID_API_KEY=SG.xxxxxxxxxxxxxxxxxxxx
```
4. Install package: `pip install sendgrid`
### Mailgun
1. Create account at [mailgun.com](https://mailgun.com)
2. Add and verify your domain
3. Get API key from Domain Settings
4. Configure:
```env
EMAIL_PROVIDER=mailgun
MAILGUN_API_KEY=key-xxxxxxxxxxxxxxxx
MAILGUN_DOMAIN=mg.yourdomain.com
```
### Amazon SES
1. Set up SES in AWS Console
2. Verify sender domain/email
3. Create IAM user with SES permissions
4. Configure:
```env
EMAIL_PROVIDER=ses
AWS_ACCESS_KEY_ID=AKIAXXXXXXXXXXXXXXXX
AWS_SECRET_ACCESS_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
AWS_REGION=eu-west-1
```
5. Install package: `pip install boto3`
## Email Logging
All emails are logged to the `email_logs` table. Query examples:
```python
# Get failed emails
failed = db.query(EmailLog).filter(
EmailLog.status == EmailStatus.FAILED.value
).all()
# Get emails for a store
store_emails = db.query(EmailLog).filter(
EmailLog.store_id == store_id
).order_by(EmailLog.created_at.desc()).all()
# Get recent signup emails
signups = db.query(EmailLog).filter(
EmailLog.template_code == "signup_welcome",
EmailLog.created_at >= datetime.now() - timedelta(days=7)
).all()
```
## Language Fallback
The system automatically falls back to English if a template isn't available in the requested language:
1. Request template for "de" (German)
2. If not found, try "en" (English)
3. If still not found, return None (log error)
## Testing
Run email service tests:
```bash
pytest tests/unit/services/test_email_service.py -v
```
Test coverage includes:
- Provider abstraction (Debug, SMTP, etc.)
- Template rendering with Jinja2
- Language fallback behavior
- Email sending success/failure
- EmailLog model methods
- Template variable handling
## Architecture
```
app/services/email_service.py # Email service with provider abstraction
models/database/email.py # EmailTemplate and EmailLog models
app/core/config.py # Email configuration settings
scripts/seed/seed_email_templates.py # Template seeding script
```
### Provider Abstraction
The system uses a strategy pattern for email providers:
```
EmailProvider (ABC)
├── SMTPProvider
├── SendGridProvider
├── MailgunProvider
├── SESProvider
└── DebugProvider
```
Each provider implements the `send()` method with the same signature, making it easy to switch providers via configuration.
## Future Enhancements
Planned improvements:
1. **Email Queue**: Background task queue for high-volume sending
2. **Webhook Tracking**: Track deliveries, opens, clicks via provider webhooks
3. **Template Editor**: Admin UI for editing templates
4. **A/B Testing**: Test different email versions
5. **Scheduled Emails**: Send emails at specific times

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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)

View File

@@ -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) |

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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
<!-- app/templates/invoices/invoice.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: Arial, sans-serif; font-size: 11pt; }
.header { display: flex; justify-content: space-between; margin-bottom: 30px; }
.invoice-title { font-size: 24pt; color: #333; }
.parties { display: flex; justify-content: space-between; margin-bottom: 30px; }
.party-box { width: 45%; }
.party-label { font-weight: bold; color: #666; margin-bottom: 5px; }
table { width: 100%; border-collapse: collapse; margin-bottom: 30px; }
th { background: #f5f5f5; padding: 10px; text-align: left; border-bottom: 2px solid #ddd; }
td { padding: 10px; border-bottom: 1px solid #eee; }
.amount { text-align: right; }
.totals { width: 300px; margin-left: auto; }
.totals td { padding: 5px 10px; }
.totals .total-row { font-weight: bold; font-size: 14pt; border-top: 2px solid #333; }
.footer { margin-top: 50px; font-size: 9pt; color: #666; }
.vat-note { background: #f9f9f9; padding: 10px; margin-top: 20px; font-size: 9pt; }
</style>
</head>
<body>
<div class="header">
<div class="invoice-title">FACTURE</div>
<div>
<strong>{{ invoice.invoice_number }}</strong><br>
Date: {{ invoice.invoice_date.strftime('%d/%m/%Y') }}
</div>
</div>
<div class="parties">
<div class="party-box">
<div class="party-label">De:</div>
<strong>{{ seller.merchant_name }}</strong><br>
{{ seller.address }}<br>
{{ seller.postal_code }} {{ seller.city }}<br>
{{ seller.country }}<br>
{% if seller.vat_number %}TVA: {{ seller.vat_number }}{% endif %}
</div>
<div class="party-box">
<div class="party-label">Facturé à:</div>
<strong>{{ buyer.name }}</strong><br>
{% if buyer.merchant %}{{ buyer.merchant }}<br>{% endif %}
{{ buyer.address }}<br>
{{ buyer.postal_code }} {{ buyer.city }}<br>
{{ buyer.country }}<br>
{% if buyer.vat_number %}TVA: {{ buyer.vat_number }}{% endif %}
</div>
</div>
{% if invoice.order_id %}
<p><strong>Référence commande:</strong> {{ invoice.order_id }}</p>
{% endif %}
<table>
<thead>
<tr>
<th>Description</th>
<th>Qté</th>
<th class="amount">Prix unit. HT</th>
<th class="amount">Total HT</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.description }}{% if item.sku %} <small>({{ item.sku }})</small>{% endif %}</td>
<td>{{ item.quantity }}</td>
<td class="amount">€{{ "%.2f"|format(item.unit_price_net) }}</td>
<td class="amount">€{{ "%.2f"|format(item.line_total_net) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<table class="totals">
<tr>
<td>Sous-total HT:</td>
<td class="amount">€{{ "%.2f"|format(invoice.subtotal_net) }}</td>
</tr>
<tr>
<td>{{ vat_label }} ({{ invoice.vat_rate }}%):</td>
<td class="amount">€{{ "%.2f"|format(invoice.vat_amount) }}</td>
</tr>
<tr class="total-row">
<td>TOTAL TTC:</td>
<td class="amount">€{{ "%.2f"|format(invoice.total_gross) }}</td>
</tr>
</table>
{% if invoice.vat_regime == 'reverse_charge' %}
<div class="vat-note">
<strong>Autoliquidation de la TVA</strong><br>
En application de l'article 196 de la directive 2006/112/CE, la TVA est due par le preneur.
</div>
{% elif invoice.vat_regime == 'oss' %}
<div class="vat-note">
<strong>Régime OSS (One-Stop-Shop)</strong><br>
TVA calculée selon le taux du pays de destination ({{ invoice.destination_country }}).
</div>
{% endif %}
<div class="footer">
{% if seller.payment_terms %}{{ seller.payment_terms }}<br>{% endif %}
{% if seller.bank_details %}{{ seller.bank_details }}<br>{% endif %}
{% if seller.footer_text %}{{ seller.footer_text }}{% endif %}
</div>
</body>
</html>
```
---
## API Endpoints
```python
# app/api/v1/store/invoices.py
@router.post("/orders/{order_id}/invoice")
async def create_invoice_from_order(
order_id: UUID,
store: Store = Depends(get_current_store),
db: Session = Depends(get_db)
):
"""Generate invoice for an order."""
service = InvoiceService(db, VATService(db))
invoice = service.create_invoice_from_order(order_id, store.id)
return InvoiceResponse.from_orm(invoice)
@router.get("/invoices/{invoice_id}/pdf")
async def download_invoice_pdf(
invoice_id: UUID,
store: Store = Depends(get_current_store),
db: Session = Depends(get_db)
):
"""Download invoice as PDF."""
invoice = db.query(Invoice).filter(
Invoice.id == invoice_id,
Invoice.store_id == store.id
).first()
if not invoice:
raise HTTPException(404, "Invoice not found")
pdf_service = InvoicePDFService()
pdf_bytes = pdf_service.generate_pdf(invoice)
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": f"attachment; filename={invoice.invoice_number}.pdf"
}
)
@router.get("/invoices")
async def list_invoices(
store: Store = Depends(get_current_store),
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 50
):
"""List all invoices for store."""
invoices = db.query(Invoice).filter(
Invoice.store_id == store.id
).order_by(Invoice.invoice_date.desc()).offset(skip).limit(limit).all()
return [InvoiceResponse.from_orm(inv) for inv in invoices]
```
---
## UI Integration
### Order Detail - Invoice Button
```html
<!-- In order detail view -->
{% if not order.invoice %}
<button
@click="generateInvoice('{{ order.id }}')"
class="btn btn-secondary">
<i data-lucide="file-text"></i>
Generate Invoice
</button>
{% else %}
<a
href="/api/v1/store/invoices/{{ order.invoice.id }}/pdf"
class="btn btn-secondary"
target="_blank">
<i data-lucide="download"></i>
Download Invoice ({{ order.invoice.invoice_number }})
</a>
{% endif %}
```
### Store Settings - VAT Configuration
```html
<!-- New settings tab for VAT/Invoice configuration -->
<div class="settings-section">
<h3>Invoice Settings</h3>
<div class="form-group">
<label>Merchant Name (for invoices)</label>
<input type="text" x-model="settings.merchant_name" required>
</div>
<div class="form-group">
<label>Merchant Address</label>
<textarea x-model="settings.merchant_address" rows="3"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>Postal Code</label>
<input type="text" x-model="settings.merchant_postal_code">
</div>
<div class="form-group">
<label>City</label>
<input type="text" x-model="settings.merchant_city">
</div>
</div>
<div class="form-group">
<label>VAT Number</label>
<input type="text" x-model="settings.vat_number" placeholder="LU12345678">
</div>
<div class="form-group">
<label>
<input type="checkbox" x-model="settings.is_oss_registered">
Registered for OSS (One-Stop-Shop)
</label>
<small>Check this if you're registered to report EU VAT through the OSS system</small>
</div>
<div class="form-group">
<label>Invoice Number Prefix</label>
<input type="text" x-model="settings.invoice_prefix" placeholder="INV">
</div>
<div class="form-group">
<label>Payment Terms</label>
<input type="text" x-model="settings.payment_terms" placeholder="Due upon receipt">
</div>
<div class="form-group">
<label>Bank Details (optional)</label>
<textarea x-model="settings.bank_details" rows="2" placeholder="IBAN: LU..."></textarea>
</div>
</div>
```
---
## Implementation Effort
| Component | Estimate |
|-----------|----------|
| Database migrations | 0.5 day |
| VAT rates seed data | 0.5 day |
| VATService | 1 day |
| InvoiceService | 1 day |
| PDF generation | 1.5 days |
| API endpoints | 0.5 day |
| UI (settings + order button) | 1 day |
| Testing | 1 day |
| **Total** | **~7 days** |
---
## Future Enhancements
1. **VIES VAT Validation** - Verify B2B VAT numbers via EU API
2. **Credit Notes** - For returns/refunds
3. **Batch Invoice Generation** - Generate for multiple orders
4. **Email Delivery** - Send invoice PDF by email
5. **Accounting Export** - CSV/XML for accounting software

View File

@@ -0,0 +1,46 @@
# Payment Gateways
Core payment gateway integrations for Stripe, PayPal, and bank transfers. Provides payment processing, refunds, and payment method management. Required by billing module for subscription payments.
## Overview
| Aspect | Detail |
|--------|--------|
| Code | `payments` |
| Classification | Core |
| Dependencies | None |
| Status | Active |
## Features
- `payment_processing` — Core payment transaction processing
- `payment_refunds` — Refund handling
- `payment_methods` — Payment method management
- `stripe_gateway` — Stripe integration
- `paypal_gateway` — PayPal integration
- `bank_transfer` — Bank transfer support
- `transaction_history` — Transaction audit trail
## Permissions
| Permission | Description |
|------------|-------------|
| `payments.view_gateways` | View payment gateway configuration |
| `payments.manage_gateways` | Configure payment gateways |
| `payments.view_transactions` | View transaction history |
## Data Model
Payment models are managed at the framework level (Stripe webhooks, payment intents).
## API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| `*` | `/api/v1/admin/payments/*` | Admin payment management |
| `*` | `/api/v1/store/payments/*` | Store payment settings |
| `POST` | `/api/v1/payments/webhooks/*` | Payment gateway webhooks |
## Configuration
Configured via Stripe/PayPal environment variables at the platform level.

View File

@@ -0,0 +1,58 @@
# Prospecting
Lead discovery, scoring, and campaign management for Luxembourg businesses.
## Overview
| Aspect | Detail |
|--------|--------|
| Code | `prospecting` |
| Classification | Optional |
| Dependencies | None |
| Status | Active |
## Features
- `domain_scanning` — Automated domain and website scanning
- `offline_capture` — Offline business data capture
- `opportunity_scoring` — Lead scoring algorithms
- `campaign_management` — Outreach campaign management
- `lead_export` — Lead data export
## Permissions
| Permission | Description |
|------------|-------------|
| `prospecting.view` | View prospects |
| `prospecting.manage` | Manage prospect data |
| `prospecting.scan` | Run domain scans |
| `prospecting.campaigns` | Manage campaigns |
| `prospecting.export` | Export prospect data |
## Data Model
- **Campaign** — Outreach campaign records
- **Interaction** — Prospect interaction tracking
- **PerformanceProfile** — Website performance analysis
- **ProspectContact** — Prospect contact information
## API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| `*` | `/api/v1/admin/prospects/*` | Prospect management |
| `*` | `/api/v1/admin/campaigns/*` | Campaign management |
| `*` | `/api/v1/admin/leads/*` | Lead management |
| `*` | `/api/v1/admin/enrichment/*` | Data enrichment |
| `*` | `/api/v1/admin/interactions/*` | Interaction tracking |
## Configuration
No module-specific configuration.
## Additional Documentation
- [Database Schema](database.md) — Database schema and entity relationships
- [Scoring Algorithm](scoring.md) — Lead scoring algorithm details
- [Research Findings](research-findings.md) — Research findings and analysis
- [User Journeys](user-journeys.md) — Prospecting user journeys

View File

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

View File

@@ -0,0 +1,160 @@
# Tenancy Data Model
## Entity Relationship Overview
```
┌──────────────┐
│ Platform │
└──────┬───────┘
│ 1:N
┌──────────────┐ ┌─────────────────┐
│ Merchant │────▶│ MerchantDomain │
└──────┬───────┘ 1:N └─────────────────┘
│ 1:N
┌──────────────┐ ┌─────────────┐ ┌──────────────┐
│ Store │────▶│ StoreUser │────▶│ Role │
└──────┬───────┘ 1:N └──────┬──────┘ └──────────────┘
│ │
│ ▼
│ ┌──────────────┐
│ │ User │
│ └──────────────┘
├────▶ StoreDomain (1:N)
└────▶ StorePlatform (N:M bridge to Platform)
```
## Core Entities
### Platform
Top-level SaaS instance. All merchants and stores belong to a platform.
| Column | Type | Description |
|--------|------|-------------|
| `id` | Integer | Primary key |
| `name` | String | Platform name |
| `code` | String | Unique platform code |
| `is_active` | Boolean | Platform enabled/disabled |
### Merchant
Business entity that owns one or more stores.
| Column | Type | Description |
|--------|------|-------------|
| `id` | Integer | Primary key |
| `name` | String | Business name |
| `owner_user_id` | Integer | FK to User — the merchant owner |
| `contact_email` | String | Primary contact email |
| `is_active` | Boolean | Merchant active status |
### Store
A storefront belonging to a merchant. Central entity for multi-tenant scoping.
| Column | Type | Description |
|--------|------|-------------|
| `id` | Integer | Primary key |
| `merchant_id` | Integer | FK to Merchant |
| `name` | String | Store name |
| `code` | String | Unique store code (used in URLs) |
| `is_active` | Boolean | Store active status |
### User
Admin users at platform, merchant, or store level.
| Column | Type | Description |
|--------|------|-------------|
| `id` | Integer | Primary key |
| `email` | String | Unique email |
| `username` | String | Username |
| `role` | UserRole | `super_admin`, `platform_admin`, `merchant_owner`, `store_member` |
| `hashed_password` | String | Bcrypt password hash |
| `is_active` | Boolean | Account active status |
### StoreUser
Membership link between users and stores, with role assignment.
| Column | Type | Description |
|--------|------|-------------|
| `id` | Integer | Primary key |
| `store_id` | Integer | FK to Store |
| `user_id` | Integer | FK to User |
| `role_id` | Integer | FK to Role (NULL for owners) |
| `is_active` | Boolean | Membership active |
| `invitation_token` | String | Pending invitation token |
| `invitation_accepted_at` | DateTime | When invitation was accepted |
### Role
Permission roles for store team members.
| Column | Type | Description |
|--------|------|-------------|
| `id` | Integer | Primary key |
| `store_id` | Integer | FK to Store |
| `name` | String | Role name (Manager, Staff, etc.) |
| `permissions` | JSON | Array of permission strings |
## Supporting Entities
### AdminPlatform
Links admin users to platforms they can manage.
### StorePlatform
N:M bridge between stores and platforms.
### StoreDomain
Custom domain configuration for stores.
### MerchantDomain
Custom domain configuration for merchants.
### EmailVerificationToken
Tokens for verifying email addresses during registration.
### UserPasswordResetToken
Tokens for password reset flow.
### AdminAuditLog
Audit trail for admin actions.
### AdminSession
Active admin login sessions.
### AdminSetting
Platform/store-level admin settings (key-value).
### PlatformAlert
System alerts displayed to platform admins.
### PlatformModule
Tracks which modules are enabled per platform.
### ApplicationLog
Application log entries for the monitoring module.
## Key Relationships
- A **Platform** has many **Merchants** (via StorePlatform)
- A **Merchant** has many **Stores** and one **owner User**
- A **Store** has many **StoreUsers** (team members)
- A **StoreUser** has one **Role** (except owners who have all permissions)
- **User.role** determines the user type: `super_admin` > `platform_admin` > `merchant_owner` > `store_member`

View File

@@ -0,0 +1,64 @@
# Tenancy Management
Platform, merchant, store, and admin user management. Required for multi-tenant operation.
## Overview
| Aspect | Detail |
|--------|--------|
| Code | `tenancy` |
| Classification | Core |
| Dependencies | None |
| Status | Active |
## Features
- `platform_management` — Platform-level administration
- `merchant_management` — Merchant account management
- `store_management` — Store configuration and setup
- `admin_user_management` — Admin user accounts and team management
## Permissions
| Permission | Description |
|------------|-------------|
| `team.view` | View team members |
| `team.invite` | Invite team members (owner only) |
| `team.edit` | Edit team members (owner only) |
| `team.remove` | Remove team members (owner only) |
## Data Model
See [Data Model](data-model.md) for full entity relationships.
- **Platform** — Top-level SaaS instances
- **Merchant** — Business entities that own stores
- **Store** — Storefronts with multi-tenant scoping
- **User** — Admin users (super_admin, platform_admin, merchant_owner, store_member)
- **StoreUser** — Store membership with role assignment
- **Role** — Permission roles for team members
- **StoreDomain / MerchantDomain** — Custom domain configuration
- **EmailVerificationToken** — Email verification tokens
## API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| `*` | `/api/v1/admin/auth/*` | Authentication (login, register, password reset) |
| `*` | `/api/v1/admin/merchants/*` | Merchant CRUD |
| `*` | `/api/v1/admin/modules/*` | Module enable/disable |
| `*` | `/api/v1/admin/merchant-domains/*` | Domain management |
| `*` | `/api/v1/admin/store-roles/*` | Admin role management |
| `*` | `/api/v1/store/team/*` | Team & role management |
| `*` | `/api/v1/store/onboarding/*` | Store onboarding wizard |
## Configuration
No module-specific configuration.
## Additional Documentation
- [Data Model](data-model.md) — Entity relationships and database schema
- [RBAC System](rbac.md) — Role-based access control, permissions, pre-defined roles
- [Store Onboarding](onboarding.md) — 4-step onboarding wizard
- [Migration Plan](migration.md) — Code migration plan from legacy structure

View File

@@ -0,0 +1,243 @@
# Tenancy Module Migration Plan
This document outlines the complete migration plan for the tenancy module, which manages the multi-tenant organizational hierarchy: platforms, merchants, stores, and users.
## Tenancy Module Domain
The tenancy module owns **identity and organizational hierarchy**:
- **Platforms** - Top-level SaaS instances
- **Merchants** - Business entities that own stores
- **Stores** - Storefronts/merchant accounts
- **Users** - Admin users, store team members
- **Authentication** - Login, tokens, sessions
- **Teams** - Store team management
- **Domains** - Custom domain configuration
## Migration Overview
```
┌─────────────────────────────────────────────────────────────────────┐
│ CURRENT STATE │
│ │
│ app/api/v1/admin/ app/api/v1/store/ app/services/ │
│ ├── admin_users.py ├── auth.py ├── store_service │
│ ├── merchants.py ├── profile.py ├── merchant_service │
│ ├── platforms.py ├── team.py ├── platform_service │
│ ├── stores.py └── ... ├── auth_service │
│ ├── users.py └── ... │
│ └── auth.py │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ TARGET STATE │
│ │
│ app/modules/tenancy/ │
│ ├── routes/api/ │
│ │ ├── admin.py # All admin tenancy routes │
│ │ └── store.py # All store tenancy routes │
│ ├── services/ │
│ │ ├── store_service.py │
│ │ ├── merchant_service.py │
│ │ ├── platform_service.py │
│ │ ├── auth_service.py │
│ │ └── team_service.py │
│ ├── models/ │
│ │ ├── store.py │
│ │ ├── merchant.py │
│ │ ├── platform.py │
│ │ └── user.py │
│ └── schemas/ │
│ └── ... │
└─────────────────────────────────────────────────────────────────────┘
```
## Files to Migrate to Tenancy
### Routes (Admin API)
| Current Location | Target Location | Description |
|-----------------|-----------------|-------------|
| `app/api/v1/admin/admin_users.py` | `tenancy/routes/api/admin_users.py` | Admin user CRUD |
| `app/api/v1/admin/merchants.py` | `tenancy/routes/api/admin_merchants.py` | Merchant management |
| `app/api/v1/admin/platforms.py` | `tenancy/routes/api/admin_platforms.py` | Platform management |
| `app/api/v1/admin/stores.py` | `tenancy/routes/api/admin_stores.py` | Store management |
| `app/api/v1/admin/store_domains.py` | `tenancy/routes/api/admin_store_domains.py` | Domain configuration |
| `app/api/v1/admin/users.py` | `tenancy/routes/api/admin_users.py` | Platform users |
| `app/api/v1/admin/auth.py` | `tenancy/routes/api/admin_auth.py` | Admin authentication |
### Routes (Store API)
| Current Location | Target Location | Description |
|-----------------|-----------------|-------------|
| `app/api/v1/store/auth.py` | `tenancy/routes/api/store_auth.py` | Store authentication |
| `app/api/v1/store/profile.py` | `tenancy/routes/api/store_profile.py` | Store profile |
| `app/api/v1/store/team.py` | `tenancy/routes/api/store_team.py` | Team management |
### Services
| Current Location | Target Location | Description |
|-----------------|-----------------|-------------|
| `app/services/store_service.py` | `tenancy/services/store_service.py` | Core store operations |
| `app/services/merchant_service.py` | `tenancy/services/merchant_service.py` | Merchant management |
| `app/services/platform_service.py` | `tenancy/services/platform_service.py` | Platform management |
| `app/services/admin_service.py` | `tenancy/services/admin_service.py` | Admin user operations |
| `app/services/admin_platform_service.py` | `tenancy/services/admin_platform_service.py` | Admin-platform relations |
| `app/services/store_domain_service.py` | `tenancy/services/store_domain_service.py` | Domain management |
| `app/services/store_team_service.py` | `tenancy/services/store_team_service.py` | Team management |
| `app/services/team_service.py` | `tenancy/services/team_service.py` | Team operations |
| `app/services/auth_service.py` | `tenancy/services/auth_service.py` | Authentication logic |
| `app/services/platform_signup_service.py` | `tenancy/services/platform_signup_service.py` | Platform onboarding |
### Models
| Current Location | Target Location | Description |
|-----------------|-----------------|-------------|
| `models/database/store.py` | `tenancy/models/store.py` | Store entity |
| `models/database/merchant.py` | `tenancy/models/merchant.py` | Merchant entity |
| `models/database/platform.py` | `tenancy/models/platform.py` | Platform entity |
| `models/database/admin.py` | `tenancy/models/admin.py` | Admin user entity |
| `models/database/admin_platform.py` | `tenancy/models/admin_platform.py` | Admin-Platform relation |
| `models/database/store_domain.py` | `tenancy/models/store_domain.py` | Store domains |
| `models/database/store_platform.py` | `tenancy/models/store_platform.py` | Store-Platform relation |
| `models/database/user.py` | `tenancy/models/user.py` | User base model |
### Schemas
| Current Location | Target Location | Description |
|-----------------|-----------------|-------------|
| `models/schema/store.py` | `tenancy/schemas/store.py` | Store schemas |
| `models/schema/merchant.py` | `tenancy/schemas/merchant.py` | Merchant schemas |
| `models/schema/platform.py` | `tenancy/schemas/platform.py` | Platform schemas |
| `models/schema/admin.py` | `tenancy/schemas/admin.py` | Admin schemas |
| `models/schema/auth.py` | `tenancy/schemas/auth.py` | Auth schemas |
## Files to Keep in ROOT (Framework Level)
These are **framework infrastructure**, not domain logic:
| File | Reason |
|------|--------|
| `app/api/v1/admin/modules.py` | Module system management |
| `app/api/v1/admin/module_config.py` | Module configuration |
| `app/api/v1/admin/menu_config.py` | Menu system |
| `app/api/v1/admin/settings.py` | System settings |
| `models/database/base.py` | SQLAlchemy base |
| `models/database/platform_module.py` | Module enablement |
| `models/database/admin_menu_config.py` | Menu configuration |
## Target Module Structure
After migration, tenancy module will have this structure:
```
app/modules/tenancy/
├── __init__.py
├── definition.py
├── exceptions.py
├── config.py # Environment configuration
├── routes/
│ ├── __init__.py
│ ├── api/
│ │ ├── __init__.py
│ │ ├── admin.py # Aggregates admin sub-routers
│ │ ├── admin_users.py # Admin user management
│ │ ├── admin_merchants.py # Merchant management
│ │ ├── admin_platforms.py # Platform management
│ │ ├── admin_stores.py # Store management
│ │ ├── admin_store_domains.py
│ │ ├── admin_auth.py # Admin authentication
│ │ ├── store.py # Aggregates store sub-routers
│ │ ├── store_auth.py # Store authentication
│ │ ├── store_profile.py # Store profile
│ │ ├── store_team.py # Team management
│ │ └── store_info.py # Public store lookup (DONE)
│ └── pages/
│ ├── __init__.py
│ ├── admin.py # Admin HTML pages
│ └── store.py # Store HTML pages
├── services/
│ ├── __init__.py
│ ├── store_service.py
│ ├── merchant_service.py
│ ├── platform_service.py
│ ├── admin_service.py
│ ├── auth_service.py
│ ├── team_service.py
│ └── store_domain_service.py
├── models/
│ ├── __init__.py
│ ├── store.py
│ ├── merchant.py
│ ├── platform.py
│ ├── admin.py
│ ├── user.py
│ ├── store_domain.py
│ └── store_platform.py
├── schemas/
│ ├── __init__.py
│ ├── store.py
│ ├── merchant.py
│ ├── platform.py
│ ├── admin.py
│ └── auth.py
├── templates/
│ └── tenancy/
│ ├── admin/
│ └── store/
├── static/
│ ├── admin/js/
│ └── store/js/
└── locales/
├── en.json
├── de.json
├── fr.json
└── lb.json
```
## Module Ownership Summary
| Module | Owns | Key Principle |
|--------|------|---------------|
| **tenancy** | Stores, Merchants, Platforms, Users, Auth, Teams | Identity & organizational hierarchy |
| **core** | Dashboard, Settings | Foundational non-domain features |
| **messaging** | Messages, Notifications, Email | Communication |
| **cms** | Media, Images, Themes, Content | Content management |
| **monitoring** | Logs, Health, Tasks, Audit | Observability |
| **dev_tools** | Code quality, Tests | Development tools |
| **ROOT** | Module system, Menu config, Base models | Framework infrastructure |
## Migration Order
Recommended order for migrating tenancy:
1. **Phase 1: Services** (no route changes)
- Move services to `tenancy/services/`
- Update imports throughout codebase
- Keep re-exports in `app/services/` temporarily
2. **Phase 2: Models** (careful - many dependencies)
- Move models to `tenancy/models/`
- Update all imports
- May need re-exports in `models/database/`
3. **Phase 3: Schemas**
- Move schemas to `tenancy/schemas/`
- Update route imports
4. **Phase 4: Routes**
- Move routes to `tenancy/routes/api/`
- Update aggregation in admin/store `__init__.py`
- Delete legacy route files
5. **Phase 5: Cleanup**
- Remove re-exports
- Delete empty legacy files
- Update documentation
## Related Documentation
- [Module System Architecture](../../architecture/module-system.md)
- [Module Auto-Discovery Migration](../../development/migration/module-autodiscovery-migration.md)
- [Multi-Tenant Architecture](../../architecture/multi-tenant.md)

View File

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

View File

@@ -0,0 +1,686 @@
# Store RBAC System - Complete Guide
## Overview
The store dashboard implements a **Role-Based Access Control (RBAC)** system that distinguishes between **Owners** and **Team Members**, with granular permissions for team members.
---
## User Types
### 1. Merchant Owner
**Who:** The user who created/owns the store account.
**Characteristics:**
- Has **ALL permissions** automatically (no role needed)
- Cannot be removed or have permissions restricted
- Can invite team members
- Can create and manage roles
- Identified by `User.role = "merchant_owner"` and `Merchant.owner_user_id`
- `User.is_merchant_owner` property returns `True`
- `User.is_store_user` property returns `True`
- Ownership checked via `User.is_owner_of(store_id)`
**Database:**
```python
# User record for merchant owner
{
"id": 5,
"role": "merchant_owner", # Role on User model
}
# StoreUser record for owner
{
"store_id": 1,
"user_id": 5,
"role_id": None, # No role needed (owner has all perms)
"is_active": True
}
# Merchant record links ownership
{
"owner_user_id": 5 # Links to User.id
}
```
**Note:** The `user_type` column was removed from StoreUser. Ownership is determined by `User.role == "merchant_owner"` and `Merchant.owner_user_id`, not by a field on StoreUser.
**Permissions:**
- All 75 permissions (complete access)
- See full list below
---
### 2. Store Members (Team Members)
**Who:** Users invited by the merchant owner to help manage the store.
**Characteristics:**
- Have **limited permissions** based on assigned role
- Must be invited via email
- Invitation must be accepted before activation
- Can be assigned one of the pre-defined roles or custom role
- Identified by `User.role = "store_member"`
- `User.is_store_user` property returns `True`
- Permissions come from `StoreUser.role_id -> Role.permissions`
**Database:**
```python
# User record for store member
{
"id": 7,
"role": "store_member", # Role on User model
}
# StoreUser record for team member
{
"store_id": 1,
"user_id": 7,
"role_id": 3, # Role required for permission lookup
"is_active": True,
"invitation_token": None, # Accepted
"invitation_accepted_at": "2024-11-15 10:30:00"
}
# Role record
{
"id": 3,
"store_id": 1,
"name": "Manager",
"permissions": [
"dashboard.view",
"products.view",
"products.create",
"products.edit",
"orders.view",
...
]
}
```
**Permissions:**
- Limited based on assigned role
- Can have between 0 and 75 permissions
- Common roles: Manager, Staff, Support, Viewer, Marketing
---
## Permission System
### All Available Permissions (75 total)
```python
class StorePermissions(str, Enum):
# Dashboard (1)
DASHBOARD_VIEW = "dashboard.view"
# Products (6)
PRODUCTS_VIEW = "products.view"
PRODUCTS_CREATE = "products.create"
PRODUCTS_EDIT = "products.edit"
PRODUCTS_DELETE = "products.delete"
PRODUCTS_IMPORT = "products.import"
PRODUCTS_EXPORT = "products.export"
# Stock/Inventory (3)
STOCK_VIEW = "stock.view"
STOCK_EDIT = "stock.edit"
STOCK_TRANSFER = "stock.transfer"
# Orders (4)
ORDERS_VIEW = "orders.view"
ORDERS_EDIT = "orders.edit"
ORDERS_CANCEL = "orders.cancel"
ORDERS_REFUND = "orders.refund"
# Customers (4)
CUSTOMERS_VIEW = "customers.view"
CUSTOMERS_EDIT = "customers.edit"
CUSTOMERS_DELETE = "customers.delete"
CUSTOMERS_EXPORT = "customers.export"
# Marketing (3)
MARKETING_VIEW = "marketing.view"
MARKETING_CREATE = "marketing.create"
MARKETING_SEND = "marketing.send"
# Reports (3)
REPORTS_VIEW = "reports.view"
REPORTS_FINANCIAL = "reports.financial"
REPORTS_EXPORT = "reports.export"
# Settings (4)
SETTINGS_VIEW = "settings.view"
SETTINGS_EDIT = "settings.edit"
SETTINGS_THEME = "settings.theme"
SETTINGS_DOMAINS = "settings.domains"
# Team Management (4)
TEAM_VIEW = "team.view"
TEAM_INVITE = "team.invite"
TEAM_EDIT = "team.edit"
TEAM_REMOVE = "team.remove"
# Marketplace Imports (3)
IMPORTS_VIEW = "imports.view"
IMPORTS_CREATE = "imports.create"
IMPORTS_CANCEL = "imports.cancel"
```
---
## Pre-Defined Roles
### 1. Owner (All 75 permissions)
**Use case:** Store owner (automatically assigned)
- Full access to everything
- Cannot be restricted
- No role record needed (permissions checked differently)
---
### 2. Manager (43 permissions)
**Use case:** Senior staff who manage most operations
**Has access to:**
- Dashboard, Products (all), Stock (all)
- Orders (all), Customers (view, edit, export)
- Marketing (all), Reports (all including financial)
- Settings (view, theme)
- Imports (all)
**Does NOT have:**
- `customers.delete` - Cannot delete customers
- `settings.edit` - Cannot change core settings
- `settings.domains` - Cannot manage domains
- `team.*` - Cannot manage team members
---
### 3. Staff (10 permissions)
**Use case:** Daily operations staff
**Has access to:**
- Dashboard view
- Products (view, create, edit)
- Stock (view, edit)
- Orders (view, edit)
- Customers (view, edit)
---
### 4. Support (6 permissions)
**Use case:** Customer support team
**Has access to:**
- Dashboard view
- Products (view only)
- Orders (view, edit)
- Customers (view, edit)
---
### 5. Viewer (6 permissions)
**Use case:** Read-only access for reporting/audit
**Has access to:**
- Dashboard (view), Products (view), Stock (view)
- Orders (view), Customers (view), Reports (view)
---
### 6. Marketing (7 permissions)
**Use case:** Marketing team focused on campaigns
**Has access to:**
- Dashboard (view), Customers (view, export)
- Marketing (all), Reports (view)
---
## Permission Checking Logic
### How Permissions Are Checked
```python
# In User model (models/database/user.py)
def has_store_permission(self, store_id: int, permission: str) -> bool:
"""Check if user has a specific permission in a store."""
# Step 1: Check if user is owner
if self.is_owner_of(store_id):
return True # Owners have ALL permissions
# Step 2: Check team member permissions
for vm in self.store_memberships:
if vm.store_id == store_id and vm.is_active:
if vm.role and permission in vm.role.permissions:
return True # Permission found in role
# No permission found
return False
```
### Permission Checking Flow
```
Request → Middleware → Extract store from URL
Check user authentication
Check if user is owner
├── YES → Allow (all permissions)
└── NO ↓
Check if user is team member
├── NO → Deny
└── YES ↓
Check if membership is active
├── NO → Deny
└── YES ↓
Check if role has required permission
├── NO → Deny (403 Forbidden)
└── YES → Allow
```
---
## Using Permissions in Code
### 1. Require Specific Permission
**When to use:** Endpoint needs one specific permission
```python
from fastapi import APIRouter, Depends
from app.api.deps import require_store_permission
from app.core.permissions import StorePermissions
from models.database.user import User
router = APIRouter()
@router.post("/products")
def create_product(
product_data: ProductCreate,
user: User = Depends(
require_store_permission(StorePermissions.PRODUCTS_CREATE.value)
)
):
# Create product...
pass
```
---
### 2. Require ANY Permission
**When to use:** Endpoint can be accessed with any of several permissions
```python
@router.get("/dashboard")
def view_dashboard(
user: User = Depends(
require_any_store_permission(
StorePermissions.DASHBOARD_VIEW.value,
StorePermissions.REPORTS_VIEW.value
)
)
):
# Show dashboard...
pass
```
---
### 3. Require ALL Permissions
**When to use:** Endpoint needs multiple permissions
```python
@router.post("/products/bulk-delete")
def bulk_delete_products(
user: User = Depends(
require_all_store_permissions(
StorePermissions.PRODUCTS_VIEW.value,
StorePermissions.PRODUCTS_DELETE.value
)
)
):
# Delete products...
pass
```
---
### 4. Require Owner Only
**When to use:** Endpoint is owner-only (team management, critical settings)
```python
from app.api.deps import require_store_owner
@router.post("/team/invite")
def invite_team_member(
email: str,
role_id: int,
user: User = Depends(require_store_owner)
):
# Invite team member...
pass
```
---
### 5. Get User Permissions
**When to use:** Need to check permissions in business logic
```python
from app.api.deps import get_user_permissions
@router.get("/my-permissions")
def list_my_permissions(
permissions: list = Depends(get_user_permissions)
):
return {"permissions": permissions}
```
---
## Database Schema
### StoreUser Table
```sql
CREATE TABLE store_users (
id SERIAL PRIMARY KEY,
store_id INTEGER NOT NULL REFERENCES stores(id),
user_id INTEGER NOT NULL REFERENCES users(id),
role_id INTEGER REFERENCES roles(id), -- NULL for merchant owners
invited_by INTEGER REFERENCES users(id),
invitation_token VARCHAR,
invitation_sent_at TIMESTAMP,
invitation_accepted_at TIMESTAMP,
is_active BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Note: user_type column removed. Ownership determined by
-- User.role = 'merchant_owner' and Merchant.owner_user_id.
```
### Role Table
```sql
CREATE TABLE roles (
id SERIAL PRIMARY KEY,
store_id INTEGER NOT NULL REFERENCES stores(id),
name VARCHAR(100) NOT NULL,
permissions JSON DEFAULT '[]', -- Array of permission strings
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
```
---
## Team Member Lifecycle
### 1. Invitation
```
Merchant owner invites user:
→ User created with role="store_member"
→ StoreUser created:
{
"role_id": 3, -- Assigned role
"is_active": False,
"invitation_token": "abc123...",
"invitation_sent_at": "2024-11-29 10:00:00",
"invitation_accepted_at": null
}
```
### 2. Acceptance
```
User accepts invitation → StoreUser updated:
{
"is_active": True,
"invitation_token": null,
"invitation_accepted_at": "2024-11-29 10:30:00"
}
```
### 3. Deactivation
```
Owner deactivates member → StoreUser updated:
{
"is_active": False
}
```
---
## Custom Role Management
### Overview
Store owners can create, edit, and delete custom roles with granular permission selection. Preset roles (manager, staff, support, viewer, marketing) cannot be deleted but can be edited.
### Store Role CRUD API
All endpoints require **store owner** authentication.
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/v1/store/team/roles` | List all roles (creates defaults if none exist) |
| `POST` | `/api/v1/store/team/roles` | Create a custom role |
| `PUT` | `/api/v1/store/team/roles/{id}` | Update a role's name/permissions |
| `DELETE` | `/api/v1/store/team/roles/{id}` | Delete a custom role |
**Validation rules:**
- Cannot create/rename a role to a preset name (manager, staff, etc.)
- Cannot delete preset roles
- Cannot delete a role with assigned team members
- All permission IDs are validated against the module-discovered permission catalog
### Permission Catalog API
Returns all available permissions grouped by category, with human-readable labels and descriptions.
```
GET /api/v1/store/team/permissions/catalog
```
**Required Permission:** `team.view`
**Response:**
```json
{
"categories": [
{
"id": "team",
"label": "tenancy.permissions.category.team",
"permissions": [
{
"id": "team.view",
"label": "tenancy.permissions.team_view",
"description": "tenancy.permissions.team_view_desc",
"is_owner_only": false
}
]
}
]
}
```
Permissions are discovered from all module `definition.py` files via `PermissionDiscoveryService.get_permissions_by_category()`.
### Role Editor UI
The role editor page at `/store/{store_code}/team/roles`:
- Lists all roles (preset + custom) with permission counts
- Modal for creating/editing roles with a permission matrix
- Permissions displayed with labels, IDs, and hover descriptions
- Category-level "Select All / Deselect All" toggles
- Owner-only permissions marked with an "Owner" badge
**Alpine.js component:** `storeRoles()` in `app/modules/tenancy/static/store/js/roles.js`
**Menu location:** Account section > Roles (requires `team.view` permission)
---
## Admin Store Roles Management
### Overview
Super admins and platform admins can manage roles for any store via the admin panel. Platform admins are scoped to stores within their assigned platforms.
### Admin Role CRUD API
All endpoints require **admin authentication** (`get_current_admin_api`).
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/v1/admin/store-roles?store_id=X` | List roles for a store |
| `GET` | `/api/v1/admin/store-roles/permissions/catalog` | Permission catalog |
| `POST` | `/api/v1/admin/store-roles?store_id=X` | Create a role |
| `PUT` | `/api/v1/admin/store-roles/{id}?store_id=X` | Update a role |
| `DELETE` | `/api/v1/admin/store-roles/{id}?store_id=X` | Delete a role |
### Platform Admin Scoping
Platform admins can only access stores that belong to one of their assigned platforms:
```python
# In StoreTeamService.validate_admin_store_access():
# 1. Super admin (accessible_platform_ids is None) → access all stores
# 2. Platform admin → store must exist in StorePlatform where
# platform_id is in the admin's accessible_platform_ids
```
The scoping is enforced at the service layer via `validate_admin_store_access()`, called by every admin endpoint before performing operations.
### Admin UI
Page at `/admin/store-roles`:
- Tom Select store search/selector (shared `initStoreSelector()` component)
- Platform admins see only stores in their assigned platforms
- Same role CRUD and permission matrix as the store-side UI
- Located in the "User Management" admin menu section
**Alpine.js component:** `adminStoreRoles()` in `app/modules/tenancy/static/admin/js/store-roles.js`
### Audit Trail
All role operations are logged via `AuditAggregatorService`:
| Action | Description |
|--------|-------------|
| `role.create` | Custom role created |
| `role.update` | Role name or permissions modified |
| `role.delete` | Custom role deleted |
| `member.role_change` | Team member assigned a different role |
| `member.invite` | Team member invited |
| `member.remove` | Team member removed |
---
## Error Responses
### Missing Permission
```http
HTTP 403 Forbidden
{
"error_code": "INSUFFICIENT_STORE_PERMISSIONS",
"message": "You don't have permission to perform this action",
"details": {
"required_permission": "products.delete",
"store_code": "orion"
}
}
```
### Not Owner
```http
HTTP 403 Forbidden
{
"error_code": "STORE_OWNER_ONLY",
"message": "This operation requires store owner privileges",
"details": {
"operation": "team management",
"store_code": "orion"
}
}
```
### Inactive Membership
```http
HTTP 403 Forbidden
{
"error_code": "INACTIVE_STORE_MEMBERSHIP",
"message": "Your store membership is inactive"
}
```
---
## Summary
### Merchant Owner vs Store Member
| Feature | Merchant Owner (`role="merchant_owner"`) | Store Member (`role="store_member"`) |
|---------|-------|-------------|
| **Permissions** | All 75 (automatic) | Based on role (0-75) |
| **Role Required** | No | Yes |
| **Can Be Removed** | No | Yes |
| **Team Management** | Yes | No |
| **Critical Settings** | Yes | No (usually) |
| **Invitation Required** | No (creates store) | Yes |
| **Ownership Determined By** | `Merchant.owner_user_id` | N/A |
### Permission Hierarchy
```
Merchant Owner (75 permissions)
└─ Manager (43 permissions)
└─ Staff (10 permissions)
└─ Support (6 permissions)
└─ Viewer (6 permissions, read-only)
Marketing (7 permissions, specialized)
```
### Best Practices
1. **Use Constants:** Always use `StorePermissions.PERMISSION_NAME.value`
2. **Least Privilege:** Give team members minimum permissions needed
3. **Owner Only:** Keep sensitive operations owner-only
4. **Custom Roles:** Create custom roles for specific needs
5. **Regular Audit:** Review team member permissions regularly
---
## Key Files
| File | Purpose |
|------|---------|
| `app/modules/tenancy/services/store_team_service.py` | Role CRUD, platform scoping, audit trail |
| `app/modules/tenancy/services/permission_discovery_service.py` | Permission catalog, role presets |
| `app/modules/tenancy/routes/api/store_team.py` | Store team & role API endpoints |
| `app/modules/tenancy/routes/api/admin_store_roles.py` | Admin store role API endpoints |
| `app/modules/tenancy/schemas/team.py` | Request/response schemas |
| `app/modules/tenancy/static/store/js/roles.js` | Store role editor Alpine.js component |
| `app/modules/tenancy/static/admin/js/store-roles.js` | Admin role editor Alpine.js component |
| `app/modules/tenancy/templates/tenancy/store/roles.html` | Store role editor template |
| `app/modules/tenancy/templates/tenancy/admin/store-roles.html` | Admin role editor template |