Compare commits

...

2 Commits

Author SHA1 Message Date
a77a8a3a98 feat: multi-module improvements across merchant, store, i18n, and customer systems
All checks were successful
CI / ruff (push) Successful in 12s
CI / pytest (push) Successful in 50m57s
CI / validate (push) Successful in 24s
CI / dependency-scanning (push) Successful in 29s
CI / docs (push) Successful in 40s
CI / deploy (push) Successful in 51s
- Fix platform-grouped merchant sidebar menu with core items at root level
- Add merchant store management (detail page, create store, team page)
- Fix store settings 500 error by removing dead stripe/API tab
- Move onboarding translations to module-owned locale files
- Fix onboarding banner i18n with server-side rendering + context inheritance
- Refactor login language selectors to use languageSelector() function (LANG-002)
- Move HTTPException handling to global exception handler in merchant routes (API-003)
- Add language selector to all login pages and portal headers
- Fix customer module: drop order stats from customer model, add to orders module
- Fix admin menu config visibility for super admin platform context
- Fix storefront auth and layout issues
- Add missing i18n translations for onboarding steps (en/fr/de/lb)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 23:48:25 +01:00
f141cc4e6a 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>
2026-03-08 23:38:37 +01:00
253 changed files with 23662 additions and 20646 deletions

View File

@@ -620,9 +620,9 @@ def require_menu_access(menu_item_id: str, frontend_type: "FrontendType"):
)
if user_context.is_super_admin:
# Super admin: check user-level config
platform_id = None
user_id = user_context.id
# Super admin: use platform from token if selected, else global (no filtering)
platform_id = user_context.token_platform_id
user_id = None
else:
# Platform admin: need platform context
# Try to get from request state

View File

@@ -96,6 +96,7 @@ analytics_module = ModuleDefinition(
icon="chart-bar",
route="/store/{store_code}/analytics",
order=20,
requires_permission="analytics.view",
),
],
),

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

@@ -82,7 +82,7 @@
this.loading = true;
this.error = null;
const response = await apiClient.get('/store/usage');
const response = await apiClient.get('/store/billing/usage');
this.usage = response;
this.loaded = true;

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

@@ -11,7 +11,6 @@ Requires customer authentication for order placement.
"""
import logging
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
@@ -92,15 +91,21 @@ def place_order(
},
)
# Update customer stats
customer.total_orders = (customer.total_orders or 0) + 1
customer.total_spent = (customer.total_spent or 0) + order.total_amount
customer.last_order_date = datetime.now(UTC)
db.flush()
# Update customer order stats (owned by orders module)
from app.modules.orders.services.customer_order_service import (
customer_order_service,
)
stats = customer_order_service.record_order(
db=db,
store_id=store.id,
customer_id=customer.id,
total_amount_cents=order.total_amount_cents,
)
logger.debug(
f"Updated customer stats: total_orders={customer.total_orders}, "
f"total_spent={customer.total_spent}"
f"Updated customer order stats: total_orders={stats.total_orders}, "
f"total_spent_cents={stats.total_spent_cents}"
)
# Clear cart (get session_id from request cookies or headers)

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

@@ -23,8 +23,8 @@ Usage:
return [
OnboardingStepDefinition(
key="marketplace.connect_api",
title_key="onboarding.marketplace.connect_api.title",
description_key="onboarding.marketplace.connect_api.description",
title_key="marketplace.onboarding.connect_api.title",
description_key="marketplace.onboarding.connect_api.description",
icon="plug",
route_template="/store/{store_code}/letzshop",
order=200,

View File

@@ -66,7 +66,6 @@ core_module = ModuleDefinition(
"dashboard",
"settings",
"email-templates",
"my-menu",
],
FrontendType.STORE: [
"dashboard",
@@ -112,15 +111,6 @@ core_module = ModuleDefinition(
order=10,
is_mandatory=True,
),
MenuItemDefinition(
id="my-menu",
label_key="core.menu.my_menu",
icon="view-grid",
route="/admin/my-menu",
order=30,
is_mandatory=True,
is_super_admin_only=True,
),
],
),
],

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

@@ -72,7 +72,6 @@
"dashboard": "Dashboard",
"platform_settings": "Plattform-Einstellungen",
"general": "Allgemein",
"my_menu": "Mein Menü",
"account_settings": "Kontoeinstellungen",
"profile": "Profil",
"settings": "Einstellungen"

View File

@@ -92,7 +92,6 @@
"dashboard": "Dashboard",
"platform_settings": "Platform Settings",
"general": "General",
"my_menu": "My Menu",
"account_settings": "Account Settings",
"profile": "Profile",
"settings": "Settings"

View File

@@ -72,7 +72,6 @@
"dashboard": "Tableau de bord",
"platform_settings": "Paramètres de la plateforme",
"general": "Général",
"my_menu": "Mon menu",
"account_settings": "Paramètres du compte",
"profile": "Profil",
"settings": "Paramètres"

View File

@@ -72,7 +72,6 @@
"dashboard": "Dashboard",
"platform_settings": "Plattform-Astellungen",
"general": "Allgemeng",
"my_menu": "Mäi Menü",
"account_settings": "Kont-Astellungen",
"profile": "Profil",
"settings": "Astellungen"

View File

@@ -100,12 +100,15 @@ class AdminMenuConfig(Base, TimestampMixin):
comment="Platform scope - applies to users/stores of this platform",
)
# DEPRECATED: user_id scoping is no longer used. Super admins now use platform
# selection instead of personal menu config. DB migration to drop this column
# is a separate task.
user_id = Column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=True,
index=True,
comment="User scope - applies to this specific super admin (admin frontend only)",
comment="DEPRECATED - User scope no longer used. Kept for migration compatibility.",
)
# ========================================================================

View File

@@ -6,8 +6,6 @@ Provides menu visibility configuration for admin and store frontends:
- GET /menu-config/platforms/{platform_id} - Get menu config for a platform
- PUT /menu-config/platforms/{platform_id} - Update menu visibility for a platform
- POST /menu-config/platforms/{platform_id}/reset - Reset to defaults
- GET /menu-config/user - Get current user's menu config (super admins)
- PUT /menu-config/user - Update current user's menu config (super admins)
- GET /menu/admin - Get rendered admin menu for current user
- GET /menu/store - Get rendered store menu for current platform
@@ -316,108 +314,6 @@ async def reset_platform_menu_config(
return {"success": True, "message": "Menu configuration reset to defaults"}
# =============================================================================
# User Menu Configuration (Super Admin Only)
# =============================================================================
@router.get("/user", response_model=MenuConfigResponse)
async def get_user_menu_config(
request: Request,
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_super_admin),
):
"""
Get the current super admin's personal menu configuration.
Only super admins can configure their own admin menu.
"""
items = menu_service.get_user_menu_config(db, current_user.id)
logger.info(
f"[MENU_CONFIG] Super admin {current_user.email} fetched their personal menu config"
)
# Use user's preferred language, falling back to middleware-resolved language
language = current_user.preferred_language or getattr(
request.state, "language", "en"
)
return _build_menu_config_response(
items, FrontendType.ADMIN, language=language, user_id=current_user.id
)
@router.put("/user")
async def update_user_menu_visibility(
update_data: MenuVisibilityUpdateRequest,
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_super_admin),
):
"""
Update visibility for a single menu item for the current super admin.
Super admin only. Cannot hide mandatory items.
"""
menu_service.update_menu_visibility(
db=db,
frontend_type=FrontendType.ADMIN,
menu_item_id=update_data.menu_item_id,
is_visible=update_data.is_visible,
user_id=current_user.id,
)
db.commit()
logger.info(
f"[MENU_CONFIG] Super admin {current_user.email} updated personal menu: "
f"{update_data.menu_item_id}={update_data.is_visible}"
)
return {"success": True, "message": "Menu visibility updated"}
@router.post("/user/reset", response_model=MenuActionResponse)
async def reset_user_menu_config(
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_super_admin),
):
"""
Reset the current super admin's menu configuration (hide all except mandatory).
Super admin only.
"""
menu_service.reset_user_menu_config(db, current_user.id)
db.commit()
logger.info(
f"[MENU_CONFIG] Super admin {current_user.email} reset their personal menu config (hide all)"
)
return MenuActionResponse(
success=True, message="Menu configuration reset - all items hidden"
)
@router.post("/user/show-all", response_model=MenuActionResponse)
async def show_all_user_menu_config(
db: Session = Depends(get_db),
current_user: UserContext = Depends(get_current_super_admin),
):
"""
Show all menu items for the current super admin.
Super admin only.
"""
menu_service.show_all_user_menu_config(db, current_user.id)
db.commit()
logger.info(
f"[MENU_CONFIG] Super admin {current_user.email} enabled all menu items"
)
return MenuActionResponse(success=True, message="All menu items are now visible")
@router.post("/platforms/{platform_id}/show-all")
async def show_all_platform_menu_config(
platform_id: int = Path(..., description="Platform ID"),
@@ -467,11 +363,12 @@ async def get_rendered_admin_menu(
Used by the frontend to render the sidebar.
"""
if current_user.is_super_admin:
# Super admin: use user-level config
# Super admin: use platform config if platform selected, else global (all modules)
platform_id = current_user.token_platform_id
menu = menu_service.get_menu_for_rendering(
db=db,
frontend_type=FrontendType.ADMIN,
user_id=current_user.id,
platform_id=platform_id,
is_super_admin=True,
)
else:

View File

@@ -5,9 +5,11 @@ Merchant menu rendering endpoint.
Provides the dynamic sidebar menu for the merchant portal:
- GET /menu/render/merchant - Get rendered merchant menu for current user
Menu sections are driven by module definitions (FrontendType.MERCHANT).
Only modules enabled on platforms the merchant is actively subscribed to
will appear in the sidebar.
Menu sections are grouped by platform:
- Core items (dashboard, billing, account) appear at root level
- Platform-specific items are grouped under their platform name
- No AdminMenuConfig visibility filtering — menu is purely driven by
module definitions + module enablement + subscription status
"""
import logging
@@ -38,6 +40,9 @@ class MenuSectionResponse(BaseModel):
id: str
label: str | None = None
icon: str | None = None
platform_code: str | None = None
is_collapsible: bool = False
items: list[dict[str, Any]]
@@ -83,10 +88,12 @@ async def get_rendered_merchant_menu(
"""
Get the rendered merchant menu for the current user.
Returns the filtered menu structure based on modules enabled
on platforms the merchant is subscribed to.
Returns a platform-grouped menu structure:
- Core sections (dashboard, billing, account) at root level
- Platform-specific sections grouped under platform name
Used by the merchant frontend to render the sidebar dynamically.
Menu visibility is driven by module definitions + module enablement
+ subscription status. No AdminMenuConfig filtering.
"""
# Resolve the merchant for this user (via service layer)
merchant = menu_service.get_merchant_for_menu(db, current_user.id)
@@ -98,32 +105,25 @@ async def get_rendered_merchant_menu(
sections=[],
)
# Get union of enabled module codes across all subscribed platforms
enabled_codes = menu_service.get_merchant_enabled_module_codes(db, merchant.id)
# Resolve primary platform for AdminMenuConfig visibility lookup
primary_platform_id = menu_service.get_merchant_primary_platform_id(
# Get platform-grouped menu
core_sections, platform_sections = menu_service.get_merchant_menu_by_platform(
db, merchant.id
)
# Get filtered menu using enabled_module_codes override + platform visibility
menu = menu_service.get_menu_for_rendering(
db=db,
frontend_type=FrontendType.MERCHANT,
platform_id=primary_platform_id,
enabled_module_codes=enabled_codes,
)
# Resolve language
language = current_user.preferred_language or getattr(
request.state, "language", "en"
)
# Translate section and item labels
sections = []
for section in menu:
# Build response sections
all_sections = []
# Core sections first (translated labels)
for section in core_sections:
translated_items = []
for item in section.items:
if not item.is_module_enabled:
continue
translated_items.append(
{
"id": item.id,
@@ -133,15 +133,59 @@ async def get_rendered_merchant_menu(
}
)
sections.append(
if translated_items:
all_sections.append(
MenuSectionResponse(
id=section.id,
label=_translate_label(section.label_key, language),
icon=section.icon,
is_collapsible=section.is_collapsible,
items=translated_items,
)
)
# Platform sections (platform name as label, collapsible)
for section in platform_sections:
translated_items = []
for item in section.items:
if not item.is_module_enabled:
continue
translated_items.append(
{
"id": item.id,
"label": _translate_label(item.label_key, language),
"icon": item.icon,
"url": item.route,
}
)
if translated_items:
# Extract platform_code from section id (format: "platform-{code}")
platform_code = section.id.removeprefix("platform-")
all_sections.append(
MenuSectionResponse(
id=section.id,
label=section.label_key, # Already platform name, not a translation key
icon=section.icon,
platform_code=platform_code,
is_collapsible=True,
items=translated_items,
)
)
# Sort order: 1) Dashboard (main), 2) Platform sections, 3) Billing, 4) Account
# Core sections use their definition order; platform sections slot in between
# main=0, platform=25, billing=50, account=900
def _section_sort_key(s):
if s.platform_code:
return 25, s.id
# Map known core section IDs to their display order
core_order = {"main": 0, "billing": 50, "account": 900}
return core_order.get(s.id, 500), s.id
all_sections.sort(key=_section_sort_key)
return RenderedMenuResponse(
frontend_type=FrontendType.MERCHANT.value,
sections=sections,
sections=all_sections,
)

View File

@@ -224,14 +224,6 @@ def get_store_settings(
"is_verified": domain.is_verified,
})
# Get Stripe info from subscription (read-only, masked)
stripe_info = None
if store.subscription and store.subscription.stripe_customer_id:
stripe_info = {
"has_stripe_customer": True,
"customer_id_masked": f"cus_***{store.subscription.stripe_customer_id[-4:]}",
}
return {
# General info
"store_code": store.store_code,
@@ -297,9 +289,6 @@ def get_store_settings(
"domains": domains,
"default_subdomain": f"{store.subdomain}.letzshop.lu",
# Stripe info (read-only)
"stripe_info": stripe_info,
# Options for dropdowns
"options": {
"supported_languages": SUPPORTED_LANGUAGES,

View File

@@ -18,6 +18,7 @@ from app.modules.core.utils.page_context import get_admin_context
from app.modules.enums import FrontendType
from app.modules.tenancy.models import User
from app.templates_config import templates
from app.utils.i18n import get_jinja2_globals
router = APIRouter()
@@ -57,10 +58,13 @@ async def admin_login_page(
if current_user:
return RedirectResponse(url="/admin/dashboard", status_code=302)
return templates.TemplateResponse("tenancy/admin/login.html", {
language = getattr(request.state, "language", "en")
context = {
"request": request,
"current_language": getattr(request.state, "language", "en"),
})
"current_language": language,
**get_jinja2_globals(language),
}
return templates.TemplateResponse("tenancy/admin/login.html", context)
@router.get("/select-platform", response_class=HTMLResponse, include_in_schema=False)
@@ -69,18 +73,15 @@ async def admin_select_platform_page(
current_user: User | None = Depends(get_current_admin_optional),
):
"""
Render platform selection page for platform admins.
Render platform selection page for admins.
Platform admins with access to multiple platforms must select
which platform they want to manage before accessing the dashboard.
Super admins are redirected to dashboard (they have global access).
Super admins can optionally select a platform to scope their view.
"""
if not current_user:
return RedirectResponse(url="/admin/login", status_code=302)
if current_user.is_super_admin:
return RedirectResponse(url="/admin/dashboard", status_code=302)
return templates.TemplateResponse(
"tenancy/admin/select-platform.html",
{"request": request, "user": current_user},
@@ -124,26 +125,6 @@ async def admin_settings_page(
)
@router.get("/my-menu", response_class=HTMLResponse, include_in_schema=False)
async def admin_my_menu_config(
request: Request,
current_user: User = Depends(require_menu_access("my-menu", FrontendType.ADMIN)),
db: Session = Depends(get_db),
):
"""
Render personal menu configuration page for super admins.
Allows super admins to customize their own sidebar menu.
"""
# Only super admins can configure their own menu
if not current_user.is_super_admin:
return RedirectResponse(url="/admin/settings", status_code=302)
return templates.TemplateResponse(
"core/admin/my-menu-config.html",
get_admin_context(request, db, current_user),
)
@router.get("/features", response_class=HTMLResponse, include_in_schema=False)
async def admin_features_page(
request: Request,

View File

@@ -24,6 +24,7 @@ from app.modules.core.utils.page_context import get_context_for_frontend
from app.modules.enums import FrontendType
from app.modules.tenancy.schemas.auth import UserContext
from app.templates_config import templates
from app.utils.i18n import get_jinja2_globals
ROUTE_CONFIG = {
"prefix": "",
@@ -67,10 +68,13 @@ async def merchant_login_page(
if current_user:
return RedirectResponse(url="/merchants/dashboard", status_code=302)
return templates.TemplateResponse("tenancy/merchant/login.html", {
language = getattr(request.state, "language", "fr")
context = {
"request": request,
"current_language": getattr(request.state, "language", "fr"),
})
"current_language": language,
**get_jinja2_globals(language),
}
return templates.TemplateResponse("tenancy/merchant/login.html", context)
# ============================================================================

View File

@@ -274,6 +274,103 @@ class MenuService:
# Merchant Menu
# =========================================================================
def get_merchant_menu_by_platform(
self,
db: Session,
merchant_id: int,
) -> tuple[list, list]:
"""
Get merchant menu items grouped by platform.
Returns two lists of DiscoveredMenuSection:
- core_sections: items from core modules (always visible, no platform grouping)
- platform_sections: items from non-core modules, with platform metadata
Each platform section has its label_key replaced with the platform name
and a `platform_code` attribute added for frontend identification.
Args:
db: Database session
merchant_id: Merchant ID
Returns:
Tuple of (core_sections, platform_sections)
"""
from app.modules.billing.services.subscription_service import (
subscription_service,
)
from app.modules.registry import MODULES
from app.modules.tenancy.services.platform_service import platform_service
core_codes = {code for code, mod in MODULES.items() if mod.is_core}
# Get all sections from core modules (no platform filtering needed)
core_sections = menu_discovery_service.get_menu_sections_for_frontend(
db,
FrontendType.MERCHANT,
enabled_module_codes=core_codes,
)
# Filter to only items from core modules
for section in core_sections:
section.items = [i for i in section.items if i.module_code in core_codes]
core_sections = [s for s in core_sections if s.items]
# Get platform-specific sections
platform_sections = []
platform_ids = subscription_service.get_active_subscription_platform_ids(
db, merchant_id
)
for pid in platform_ids:
platform = platform_service.get_platform_by_id(db, pid)
if not platform:
continue
# Get modules enabled on this platform (excluding core — already at root)
platform_module_codes = module_service.get_enabled_module_codes(db, pid)
non_core_codes = platform_module_codes - core_codes
if not non_core_codes:
continue # No platform-specific items
# Get sections for this platform's non-core modules
sections = menu_discovery_service.get_menu_sections_for_frontend(
db,
FrontendType.MERCHANT,
enabled_module_codes=non_core_codes,
)
# Filter to only items from non-core modules
for section in sections:
section.items = [
i for i in section.items if i.module_code in non_core_codes
]
# Flatten all platform sections into one section per platform
all_items = []
for section in sections:
all_items.extend(section.items)
if not all_items:
continue
# Create a single section for this platform
from app.modules.core.services.menu_discovery_service import (
DiscoveredMenuSection,
)
platform_section = DiscoveredMenuSection(
id=f"platform-{platform.code}",
label_key=platform.name, # Use platform name directly
icon=getattr(platform, "icon", None) or "globe-alt",
order=30 + platform_ids.index(pid),
is_super_admin_only=False,
is_collapsible=True,
items=sorted(all_items, key=lambda i: (i.section_order, i.order)),
)
platform_sections.append(platform_section)
return core_sections, platform_sections
def get_merchant_enabled_module_codes(
self,
db: Session,
@@ -472,60 +569,6 @@ class MenuService:
return result
def get_user_menu_config(
self,
db: Session,
user_id: int,
) -> list[MenuItemConfig]:
"""
Get admin menu configuration for a super admin user.
Super admins don't have platform context, so all modules are shown.
Module enablement is always True for super admin menu config.
Args:
db: Database session
user_id: Super admin user ID
Returns:
List of MenuItemConfig with current visibility state
"""
shown_items = self._get_shown_items(db, FrontendType.ADMIN, user_id=user_id)
mandatory_items = menu_discovery_service.get_mandatory_item_ids(
FrontendType.ADMIN
)
# Get all menu items from discovery service
all_items = menu_discovery_service.get_all_menu_items(FrontendType.ADMIN)
result = []
for item in all_items:
# If no config exists (shown_items is None), show all by default
# Otherwise, item is visible if in shown_items or mandatory
is_visible = (
shown_items is None
or item.id in shown_items
or item.id in mandatory_items
)
result.append(
MenuItemConfig(
id=item.id,
label=item.label_key,
icon=item.icon,
url=item.route,
section_id=item.section_id,
section_label=item.section_label_key,
is_visible=is_visible,
is_mandatory=item.id in mandatory_items,
is_super_admin_only=item.is_super_admin_only,
is_module_enabled=True, # Super admins see all modules
module_code=item.module_code,
)
)
return result
def update_menu_visibility(
self,
db: Session,
@@ -692,54 +735,6 @@ class MenuService:
f"Created {len(all_items) - len(mandatory_items)} hidden records for platform {platform_id}"
)
def reset_user_menu_config(
self,
db: Session,
user_id: int,
) -> None:
"""
Reset menu configuration for a super admin user to defaults (all hidden except mandatory).
In opt-in model, reset means hide everything so user can opt-in to what they want.
Args:
db: Database session
user_id: Super admin user ID
"""
# Delete all existing records
deleted = (
db.query(AdminMenuConfig)
.filter(
AdminMenuConfig.frontend_type == FrontendType.ADMIN,
AdminMenuConfig.user_id == user_id,
)
.delete()
)
logger.info(f"Reset menu config for user {user_id}: deleted {deleted} rows")
# Create records with is_visible=False for all non-mandatory items
all_items = menu_discovery_service.get_all_menu_item_ids(FrontendType.ADMIN)
mandatory_items = menu_discovery_service.get_mandatory_item_ids(
FrontendType.ADMIN
)
configs = [
AdminMenuConfig(
frontend_type=FrontendType.ADMIN,
platform_id=None,
user_id=user_id,
menu_item_id=item_id,
is_visible=False,
)
for item_id in all_items
if item_id not in mandatory_items
]
db.add_all(configs)
logger.info(
f"Created {len(all_items) - len(mandatory_items)} hidden records for user {user_id}"
)
def show_all_platform_menu_config(
self,
db: Session,
@@ -789,52 +784,6 @@ class MenuService:
f"Created {len(all_items) - len(mandatory_items)} visible records for platform {platform_id}"
)
def show_all_user_menu_config(
self,
db: Session,
user_id: int,
) -> None:
"""
Show all menu items for a super admin user.
Args:
db: Database session
user_id: Super admin user ID
"""
# Delete all existing records
deleted = (
db.query(AdminMenuConfig)
.filter(
AdminMenuConfig.frontend_type == FrontendType.ADMIN,
AdminMenuConfig.user_id == user_id,
)
.delete()
)
logger.info(f"Show all menu config for user {user_id}: deleted {deleted} rows")
# Create records with is_visible=True for all non-mandatory items
all_items = menu_discovery_service.get_all_menu_item_ids(FrontendType.ADMIN)
mandatory_items = menu_discovery_service.get_mandatory_item_ids(
FrontendType.ADMIN
)
configs = [
AdminMenuConfig(
frontend_type=FrontendType.ADMIN,
platform_id=None,
user_id=user_id,
menu_item_id=item_id,
is_visible=True,
)
for item_id in all_items
if item_id not in mandatory_items
]
db.add_all(configs)
logger.info(
f"Created {len(all_items) - len(mandatory_items)} visible records for user {user_id}"
)
def initialize_menu_config(
self,
db: Session,

View File

@@ -39,11 +39,28 @@ class OnboardingAggregatorService:
a unified interface for the dashboard onboarding banner.
"""
def _get_store_platform_ids(self, db: Session, store_id: int) -> set[int]:
"""Get platform IDs the store is actively subscribed to."""
from app.modules.tenancy.models.store_platform import StorePlatform
rows = (
db.query(StorePlatform.platform_id)
.filter(
StorePlatform.store_id == store_id,
StorePlatform.is_active.is_(True),
)
.all()
)
return {r.platform_id for r in rows}
def _get_enabled_providers(
self, db: Session, platform_id: int
self, db: Session, store_id: int, platform_id: int
) -> list[tuple["ModuleDefinition", OnboardingProviderProtocol]]:
"""
Get onboarding providers from enabled modules.
Get onboarding providers from modules enabled on the store's subscribed platforms.
Filters non-core modules to only those enabled on platforms the store
is actively subscribed to, preventing cross-platform content leakage.
Returns:
List of (module, provider) tuples for enabled modules with providers
@@ -51,6 +68,11 @@ class OnboardingAggregatorService:
from app.modules.registry import MODULES
from app.modules.service import module_service
store_platform_ids = self._get_store_platform_ids(db, store_id)
if not store_platform_ids:
# Fallback to the passed platform_id if no subscriptions found
store_platform_ids = {platform_id}
providers: list[tuple[ModuleDefinition, OnboardingProviderProtocol]] = []
for module in MODULES.values():
@@ -59,13 +81,19 @@ class OnboardingAggregatorService:
# Core modules are always enabled, check others
if not module.is_core:
# Check if module is enabled on ANY of the store's subscribed platforms
enabled_on_any = False
for pid in store_platform_ids:
try:
if not module_service.is_module_enabled(db, platform_id, module.code):
continue
if module_service.is_module_enabled(db, pid, module.code):
enabled_on_any = True
break
except Exception as e:
logger.warning(
f"Failed to check if module {module.code} is enabled: {e}"
f"Failed to check if module {module.code} is enabled "
f"on platform {pid}: {e}"
)
if not enabled_on_any:
continue
try:
@@ -88,7 +116,7 @@ class OnboardingAggregatorService:
Returns:
Sorted list of OnboardingStepStatus objects
"""
providers = self._get_enabled_providers(db, platform_id)
providers = self._get_enabled_providers(db, store_id, platform_id)
steps: list[OnboardingStepStatus] = []
for module, provider in providers:

View File

@@ -52,15 +52,33 @@ class StatsAggregatorService:
when modules are disabled or providers fail.
"""
def _get_store_platform_ids(self, db: Session, store_id: int) -> set[int]:
"""Get platform IDs the store is actively subscribed to."""
from app.modules.tenancy.models.store_platform import StorePlatform
rows = (
db.query(StorePlatform.platform_id)
.filter(
StorePlatform.store_id == store_id,
StorePlatform.is_active.is_(True),
)
.all()
)
return {r.platform_id for r in rows}
def _get_enabled_providers(
self, db: Session, platform_id: int
self, db: Session, platform_id: int, store_id: int | None = None
) -> list[tuple["ModuleDefinition", MetricsProviderProtocol]]:
"""
Get metrics providers from enabled modules.
When store_id is provided, filters to modules enabled on the store's
subscribed platforms only (prevents cross-platform content leakage).
Args:
db: Database session
platform_id: Platform ID to check module enablement
store_id: Optional store ID for subscription-aware filtering
Returns:
List of (module, provider) tuples for enabled modules with providers
@@ -68,6 +86,14 @@ class StatsAggregatorService:
from app.modules.registry import MODULES
from app.modules.service import module_service
# Determine which platform IDs to check
if store_id:
check_platform_ids = self._get_store_platform_ids(db, store_id)
if not check_platform_ids:
check_platform_ids = {platform_id}
else:
check_platform_ids = {platform_id}
providers: list[tuple[ModuleDefinition, MetricsProviderProtocol]] = []
for module in MODULES.values():
@@ -77,13 +103,18 @@ class StatsAggregatorService:
# Core modules are always enabled, check others
if not module.is_core:
enabled_on_any = False
for pid in check_platform_ids:
try:
if not module_service.is_module_enabled(db, platform_id, module.code):
continue
if module_service.is_module_enabled(db, pid, module.code):
enabled_on_any = True
break
except Exception as e:
logger.warning(
f"Failed to check if module {module.code} is enabled: {e}"
f"Failed to check if module {module.code} is enabled "
f"on platform {pid}: {e}"
)
if not enabled_on_any:
continue
# Get the provider instance
@@ -119,7 +150,7 @@ class StatsAggregatorService:
Returns:
Dict mapping category name to list of MetricValue objects
"""
providers = self._get_enabled_providers(db, platform_id)
providers = self._get_enabled_providers(db, platform_id, store_id=store_id)
result: dict[str, list[MetricValue]] = {}
for module, provider in providers:

View File

@@ -264,7 +264,7 @@ function languageSelector(currentLang, enabledLanguages) {
return {
isLangOpen: false,
currentLang: currentLang || 'fr',
languages: enabledLanguages || ['fr', 'de', 'en'],
languages: enabledLanguages || ['en', 'fr', 'de', 'lb'],
languageNames: {
'en': 'English',
'fr': 'Français',

View File

@@ -6,6 +6,27 @@
// Create custom logger for login page
const loginLog = window.LogConfig.createLogger('LOGIN');
function languageSelector(currentLang, enabledLanguages) {
return {
currentLang: currentLang || 'en',
languages: enabledLanguages || ['en', 'fr', 'de', 'lb'],
async setLanguage(lang) {
if (lang === this.currentLang) return;
try {
// noqa: JS-008 - Login page has no apiClient; raw fetch is intentional
await fetch('/api/v1/platform/language/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ language: lang }),
});
window.location.reload();
} catch (error) {
loginLog.error('Failed to set language:', error);
}
},
};
}
function adminLogin() {
return {
dark: false,
@@ -17,6 +38,10 @@ function adminLogin() {
error: null,
success: null,
errors: {},
rememberMe: false,
showForgotPassword: false,
forgotPasswordEmail: '',
forgotPasswordLoading: false,
init() {
// Guard against multiple initialization
@@ -196,6 +221,14 @@ function adminLogin() {
window.location.href = '/admin/select-platform';
return;
}
if (platformsResponse.is_super_admin && !platformsResponse.current_platform_id) {
// Super admin with no platform selected - offer platform selection
loginLog.info('Super admin without platform, redirecting to platform selector...');
this.success = 'Login successful! Select a platform or stay in global mode...';
window.location.href = '/admin/select-platform';
return;
}
} catch (platformError) {
loginLog.warn('Could not check platforms, proceeding to dashboard:', platformError);
}
@@ -233,6 +266,28 @@ function adminLogin() {
}
},
async handleForgotPassword() {
if (!this.forgotPasswordEmail) {
this.error = 'Please enter your email address';
return;
}
this.forgotPasswordLoading = true;
this.clearErrors();
try {
const response = await apiClient.post('/admin/auth/forgot-password', {
email: this.forgotPasswordEmail
});
this.success = response.message || 'If an account exists with that email, a reset link has been sent.';
this.forgotPasswordEmail = '';
} catch (error) {
// Show generic message to prevent email enumeration
this.success = 'If an account exists with that email, a reset link has been sent.';
} finally {
this.forgotPasswordLoading = false;
}
},
toggleDarkMode() {
loginLog.debug('Toggling dark mode...');
this.dark = !this.dark;

View File

@@ -1,177 +0,0 @@
// static/admin/js/my-menu-config.js
// Personal menu configuration for super admins
//
// NOTE: The page method for loading user menu config is named loadUserMenuConfig()
// (not loadMenuConfig()) to avoid shadowing the sidebar's loadMenuConfig() inherited
// from data() via the spread operator. Shadowing caused the sidebar to never populate
// its menuData, resulting in a blank sidebar on this page.
const myMenuConfigLog = window.LogConfig?.loggers?.myMenuConfig || window.LogConfig?.createLogger?.('myMenuConfig') || console;
function adminMyMenuConfig() {
return {
// Inherit base layout functionality from init-alpine.js
...data(),
// Page-specific state
currentPage: 'my-menu',
loading: true,
error: null,
successMessage: null,
saving: false,
// Data
menuConfig: null,
showShowAllModal: false,
showHideAllModal: false,
// Computed grouped items
get groupedItems() {
if (!this.menuConfig?.items) return [];
// Group items by section
const sections = {};
for (const item of this.menuConfig.items) {
const sectionId = item.section_id;
if (!sections[sectionId]) {
sections[sectionId] = {
id: sectionId,
label: item.section_label,
isSuperAdminOnly: item.is_super_admin_only,
items: [],
visibleCount: 0
};
}
sections[sectionId].items.push(item);
if (item.is_visible) {
sections[sectionId].visibleCount++;
}
}
// Convert to array and maintain order
return Object.values(sections);
},
async init() {
// Guard against multiple initialization
if (window._adminMyMenuConfigInitialized) {
myMenuConfigLog.warn('Already initialized, skipping');
return;
}
window._adminMyMenuConfigInitialized = true;
myMenuConfigLog.info('=== MY MENU CONFIG PAGE INITIALIZING ===');
try {
// Load core translations for confirmations
await I18n.loadModule('core');
await this.loadUserMenuConfig();
myMenuConfigLog.info('=== MY MENU CONFIG PAGE INITIALIZED ===');
} catch (error) {
myMenuConfigLog.error('Failed to initialize my menu config page:', error);
this.error = 'Failed to load page data. Please refresh.';
}
},
async refresh() {
this.error = null;
this.successMessage = null;
await this.loadUserMenuConfig();
},
async loadUserMenuConfig() {
this.loading = true;
this.error = null;
try {
this.menuConfig = await apiClient.get('/admin/menu-config/user');
myMenuConfigLog.info('Loaded menu config:', {
totalItems: this.menuConfig?.total_items,
visibleItems: this.menuConfig?.visible_items
});
} catch (error) {
myMenuConfigLog.error('Failed to load menu config:', error);
this.error = error.message || 'Failed to load menu configuration';
} finally {
this.loading = false;
}
},
async toggleVisibility(item) {
if (item.is_mandatory) {
myMenuConfigLog.warn('Cannot toggle mandatory item:', item.id);
return;
}
this.saving = true;
this.error = null;
this.successMessage = null;
const newVisibility = !item.is_visible;
try {
await apiClient.put('/admin/menu-config/user', {
menu_item_id: item.id,
is_visible: newVisibility
});
// Update local state
item.is_visible = newVisibility;
// Update counts
if (newVisibility) {
this.menuConfig.visible_items++;
this.menuConfig.hidden_items--;
} else {
this.menuConfig.visible_items--;
this.menuConfig.hidden_items++;
}
myMenuConfigLog.info('Toggled visibility:', item.id, newVisibility);
// Reload the page to refresh sidebar
window.location.reload();
} catch (error) {
myMenuConfigLog.error('Failed to toggle visibility:', error);
this.error = error.message || 'Failed to update menu visibility';
this.saving = false;
}
},
async showAll() {
this.saving = true;
this.error = null;
this.successMessage = null;
try {
await apiClient.post('/admin/menu-config/user/show-all');
myMenuConfigLog.info('Showed all menu items');
// Reload the page to refresh sidebar
window.location.reload();
} catch (error) {
myMenuConfigLog.error('Failed to show all menu items:', error);
this.error = error.message || 'Failed to show all menu items';
this.saving = false;
}
},
async resetToDefaults() {
this.saving = true;
this.error = null;
this.successMessage = null;
try {
await apiClient.post('/admin/menu-config/user/reset');
myMenuConfigLog.info('Reset menu config to defaults');
// Reload the page to refresh sidebar
window.location.reload();
} catch (error) {
myMenuConfigLog.error('Failed to reset menu config:', error);
this.error = error.message || 'Failed to reset menu configuration';
this.saving = false;
}
}
};
}

View File

@@ -5,6 +5,27 @@
// Use centralized logger
const loginLog = window.LogConfig.createLogger('MERCHANT-LOGIN');
function languageSelector(currentLang, enabledLanguages) {
return {
currentLang: currentLang || 'fr',
languages: enabledLanguages || ['en', 'fr', 'de', 'lb'],
async setLanguage(lang) {
if (lang === this.currentLang) return;
try {
// noqa: JS-008 - Login page has no apiClient; raw fetch is intentional
await fetch('/api/v1/platform/language/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ language: lang }),
});
window.location.reload();
} catch (error) {
loginLog.error('Failed to set language:', error);
}
},
};
}
function merchantLogin() {
return {
dark: false,
@@ -134,6 +155,7 @@ function merchantLogin() {
},
// Forgot password state
rememberMe: false,
showForgotPassword: false,
forgotPasswordEmail: '',
forgotPasswordLoading: false,

View File

@@ -15,7 +15,9 @@ storeDashLog.info('[STORE DASHBOARD] data function exists?', typeof data);
* Fetches onboarding steps from API, supports session-scoped dismiss.
*/
function onboardingBanner() {
const t = (key, vars) => I18n.t(key, vars);
return {
t,
visible: false,
steps: [],
totalSteps: 0,
@@ -30,7 +32,19 @@ function onboardingBanner() {
try {
const response = await apiClient.get('/store/dashboard/onboarding');
this.steps = response.steps || [];
const steps = response.steps || [];
// Load module translations BEFORE setting reactive data
// Keys are like "tenancy.onboarding...." — first segment is the module
const modules = new Set();
for (const step of steps) {
const mod = step.title_key?.split('.')[0];
if (mod) modules.add(mod);
}
await Promise.all([...modules].map(m => I18n.loadModule(m)));
// Now set reactive data — Alpine re-renders with translations ready
this.steps = steps;
this.totalSteps = response.total_steps || 0;
this.completedSteps = response.completed_steps || 0;
this.progressPercentage = response.progress_percentage || 0;

View File

@@ -253,7 +253,7 @@ function languageSelector(currentLang, enabledLanguages) {
return {
isLangOpen: false,
currentLang: currentLang || 'fr',
languages: enabledLanguages || ['fr', 'de', 'en'],
languages: enabledLanguages || ['en', 'fr', 'de', 'lb'],
languageNames: {
'en': 'English',
'fr': 'Français',

View File

@@ -1,200 +0,0 @@
{# app/templates/admin/my-menu-config.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/alerts.html' import alert_dynamic, error_state %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/modals.html' import confirm_modal %}
{% block title %}My Menu{% endblock %}
{% block alpine_data %}adminMyMenuConfig(){% endblock %}
{% block content %}
{{ page_header('My Menu Configuration', subtitle='Customize your personal admin sidebar', back_url='/admin/settings') }}
{{ alert_dynamic(type='success', title='Success', message_var='successMessage', show_condition='successMessage') }}
{{ error_state('Error', show_condition='error') }}
<!-- Info Box -->
<div class="mb-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div class="flex items-start">
<span x-html="$icon('information-circle', 'w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 mr-3')"></span>
<div>
<p class="text-sm text-blue-800 dark:text-blue-200">
This configures <strong>your personal</strong> admin sidebar menu. These settings only affect your view.
</p>
<p class="text-sm text-blue-700 dark:text-blue-300 mt-1">
To configure menus for platform admins or stores, go to <a href="/admin/platforms" class="underline hover:no-underline">Platforms</a> and select a platform's Menu Configuration.
</p>
</div>
</div>
</div>
<!-- Stats Cards -->
<div class="grid gap-4 mb-6 md:grid-cols-3">
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
<span x-html="$icon('view-grid', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Total Items</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="menuConfig?.total_items || 0"></p>
</div>
</div>
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Visible</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="menuConfig?.visible_items || 0"></p>
</div>
</div>
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-gray-500 bg-gray-100 rounded-full dark:text-gray-100 dark:bg-gray-600">
<span x-html="$icon('eye-off', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-1 text-sm font-medium text-gray-600 dark:text-gray-400">Hidden</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="menuConfig?.hidden_items || 0"></p>
</div>
</div>
</div>
<!-- Actions -->
<div class="mb-4 flex items-center justify-between">
<p class="text-sm text-gray-500 dark:text-gray-400">
Toggle visibility for menu items. Mandatory items cannot be hidden.
</p>
<div class="flex gap-2">
<button
@click="showShowAllModal = true"
:disabled="saving"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600"
>
<span x-html="$icon('eye', 'w-4 h-4 mr-2')"></span>
Show All
</button>
<button
@click="showHideAllModal = true"
:disabled="saving"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600"
>
<span x-html="$icon('eye-off', 'w-4 h-4 mr-2')"></span>
Hide All
</button>
</div>
</div>
<!-- Loading State -->
<div x-show="loading" class="flex items-center justify-center py-12 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<span x-html="$icon('refresh', 'w-8 h-8 animate-spin text-purple-600')"></span>
<span class="ml-3 text-gray-500 dark:text-gray-400">Loading menu configuration...</span>
</div>
<!-- Menu Items by Section -->
<div x-show="!loading" class="space-y-6">
<template x-for="section in groupedItems" :key="section.id">
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 overflow-hidden">
<!-- Section Header -->
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<div class="flex items-center">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-200" x-text="section.label || 'Main'"></h3>
<span
x-show="section.isSuperAdminOnly"
class="ml-2 px-2 py-0.5 text-xs font-medium rounded bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200"
>
Super Admin Only
</span>
</div>
<span class="text-xs text-gray-500 dark:text-gray-400" x-text="`${section.visibleCount}/${section.items.length} visible`"></span>
</div>
</div>
<!-- Section Items -->
<div class="divide-y divide-gray-100 dark:divide-gray-700">
<template x-for="item in section.items" :key="item.id">
<div class="flex items-center justify-between px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/30">
<div class="flex items-center">
<span x-html="$icon(item.icon, 'w-5 h-5 text-gray-400 mr-3')"></span>
<div>
<p class="text-sm font-medium text-gray-700 dark:text-gray-200" x-text="item.label"></p>
<p class="text-xs text-gray-400 dark:text-gray-500" x-text="item.url"></p>
</div>
</div>
<div class="flex items-center gap-3">
<!-- Mandatory Badge -->
<span
x-show="item.is_mandatory"
class="px-2 py-0.5 text-xs font-medium rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
>
Mandatory
</span>
<!-- Toggle Switch -->
<button
@click="toggleVisibility(item)"
:disabled="item.is_mandatory || saving"
:class="{
'bg-purple-600': item.is_visible,
'bg-gray-200 dark:bg-gray-600': !item.is_visible,
'opacity-50 cursor-not-allowed': item.is_mandatory
}"
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2"
role="switch"
:aria-checked="item.is_visible"
>
<span
:class="{
'translate-x-5': item.is_visible,
'translate-x-0': !item.is_visible
}"
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
></span>
</button>
</div>
</div>
</template>
</div>
</div>
</template>
<!-- Empty State -->
<div x-show="groupedItems.length === 0" class="bg-white rounded-lg shadow-xs dark:bg-gray-800 p-8 text-center">
<span x-html="$icon('view-grid', 'w-12 h-12 mx-auto text-gray-400')"></span>
<p class="mt-4 text-gray-500 dark:text-gray-400">No menu items available.</p>
</div>
</div>
<!-- Confirmation Modals -->
{{ confirm_modal(
'showAllModal',
'Show All Menu Items',
'This will make all menu items visible in your sidebar. Are you sure?',
'showAll()',
'showShowAllModal',
'Show All',
'Cancel',
'info',
'eye'
) }}
{{ confirm_modal(
'hideAllModal',
'Hide All Menu Items',
'This will hide all non-mandatory menu items from your sidebar. Are you sure?',
'resetToDefaults()',
'showHideAllModal',
'Hide All',
'Cancel',
'warning',
'eye-off'
) }}
{% endblock %}
{% block extra_scripts %}
<script defer src="{{ url_for('core_static', path='admin/js/my-menu-config.js') }}"></script>
{% endblock %}

View File

@@ -8,7 +8,7 @@
{% block alpine_data %}storeDashboard(){% endblock %}
{% from "shared/macros/feature_gate.html" import email_settings_warning, onboarding_banner %}
{% from "shared/macros/feature_gate.html" import email_settings_warning, onboarding_banner with context %}
{% block content %}
<!-- Email Settings Warning -->

File diff suppressed because it is too large Load Diff

View File

@@ -263,34 +263,49 @@ class TestMerchantMenuModuleGating:
def test_loyalty_appears_when_module_enabled(
self, client, db, menu_auth, menu_merchant, menu_subscription,
menu_loyalty_module,
menu_loyalty_module, menu_platform,
):
"""Loyalty section appears when loyalty module is enabled on subscribed platform."""
"""Loyalty items appear under platform section when module is enabled."""
response = client.get(BASE, headers=menu_auth)
assert response.status_code == 200
data = response.json()
# Loyalty now appears under a platform-{code} section
platform_section_id = f"platform-{menu_platform.code}"
section_ids = {s["id"] for s in data["sections"]}
assert "loyalty" in section_ids
assert platform_section_id in section_ids
# Check loyalty item exists in that platform section
platform_section = next(
s for s in data["sections"] if s["id"] == platform_section_id
)
item_ids = {i["id"] for i in platform_section["items"]}
assert "loyalty-overview" in item_ids
def test_loyalty_hidden_when_module_not_enabled(
self, client, db, menu_auth, menu_merchant, menu_subscription,
menu_platform,
):
"""Loyalty section is hidden when loyalty module is NOT enabled."""
"""No platform section when no non-core modules are enabled."""
response = client.get(BASE, headers=menu_auth)
assert response.status_code == 200
data = response.json()
platform_section_id = f"platform-{menu_platform.code}"
section_ids = {s["id"] for s in data["sections"]}
assert "loyalty" not in section_ids
assert platform_section_id not in section_ids
def test_loyalty_item_has_correct_route(
self, client, db, menu_auth, menu_merchant, menu_subscription,
menu_loyalty_module,
menu_loyalty_module, menu_platform,
):
"""Loyalty overview item has the correct URL."""
response = client.get(BASE, headers=menu_auth)
data = response.json()
loyalty = next(s for s in data["sections"] if s["id"] == "loyalty")
overview = next(i for i in loyalty["items"] if i["id"] == "loyalty-overview")
platform_section_id = f"platform-{menu_platform.code}"
platform_section = next(
s for s in data["sections"] if s["id"] == platform_section_id
)
overview = next(
i for i in platform_section["items"] if i["id"] == "loyalty-overview"
)
assert overview["url"] == "/merchants/loyalty/overview"
@@ -352,7 +367,8 @@ class TestMerchantMenuSubscriptionStatus:
response = client.get(BASE, headers=menu_auth)
assert response.status_code == 200
section_ids = {s["id"] for s in response.json()["sections"]}
assert "loyalty" in section_ids
platform_section_id = f"platform-{menu_platform.code}"
assert platform_section_id in section_ids
def test_expired_subscription_hides_non_core_modules(
self, client, db, menu_auth, menu_merchant, menu_platform, menu_tier,
@@ -374,8 +390,9 @@ class TestMerchantMenuSubscriptionStatus:
response = client.get(BASE, headers=menu_auth)
assert response.status_code == 200
section_ids = {s["id"] for s in response.json()["sections"]}
# Loyalty should NOT appear because subscription is expired
assert "loyalty" not in section_ids
platform_section_id = f"platform-{menu_platform.code}"
# Platform section should NOT appear because subscription is expired
assert platform_section_id not in section_ids
# Core sections always appear
assert "main" in section_ids
assert "billing" in section_ids
@@ -468,9 +485,20 @@ class TestMerchantMenuMultiPlatform:
response = client.get(BASE, headers=menu_auth)
assert response.status_code == 200
section_ids = {s["id"] for s in response.json()["sections"]}
# Loyalty enabled on Platform A should appear in the union
assert "loyalty" in section_ids
data = response.json()
section_ids = {s["id"] for s in data["sections"]}
# Loyalty enabled on Platform A appears under platform-a's section
platform_a_section_id = f"platform-{platform_a.code}"
assert platform_a_section_id in section_ids
# Platform B has no non-core modules, so no section
platform_b_section_id = f"platform-{platform_b.code}"
assert platform_b_section_id not in section_ids
# Check loyalty item exists in Platform A section
pa_section = next(
s for s in data["sections"] if s["id"] == platform_a_section_id
)
item_ids = {i["id"] for i in pa_section["items"]}
assert "loyalty-overview" in item_ids
# Core sections always present
assert "main" in section_ids
assert "billing" in section_ids

View File

@@ -110,3 +110,62 @@ class TestMenuServiceMerchantRendering:
without_ids = {s.id for s in without_loyalty}
assert "loyalty" in with_ids
assert "loyalty" not in without_ids
@pytest.mark.unit
@pytest.mark.core
class TestMenuServiceMerchantByPlatform:
"""Test get_merchant_menu_by_platform grouping logic."""
def setup_method(self):
self.service = MenuService()
def test_core_sections_contain_only_core_items(self, db):
"""Core sections should only have items from core modules."""
from app.modules.core.services.menu_discovery_service import (
menu_discovery_service,
)
from app.modules.registry import MODULES
core_codes = {code for code, mod in MODULES.items() if mod.is_core}
core_sections = menu_discovery_service.get_menu_sections_for_frontend(
db,
FrontendType.MERCHANT,
enabled_module_codes=core_codes,
)
for section in core_sections:
section.items = [i for i in section.items if i.module_code in core_codes]
for section in core_sections:
for item in section.items:
assert item.module_code in core_codes, (
f"Non-core item '{item.id}' (module={item.module_code}) "
f"found in core section '{section.id}'"
)
def test_non_core_sections_for_loyalty(self, db):
"""Non-core modules like loyalty produce merchant menu sections."""
from app.modules.core.services.menu_discovery_service import (
menu_discovery_service,
)
from app.modules.registry import MODULES
core_codes = {code for code, mod in MODULES.items() if mod.is_core}
non_core = {"loyalty"}
sections = menu_discovery_service.get_menu_sections_for_frontend(
db,
FrontendType.MERCHANT,
enabled_module_codes=non_core,
)
sections = [
s
for s in sections
if any(i.module_code in non_core for i in s.items)
]
assert len(sections) > 0, "Loyalty module should produce merchant menu sections"
for section in sections:
for item in section.items:
assert item.module_code not in core_codes

View File

@@ -100,17 +100,17 @@ customers_module = ModuleDefinition(
menus={
FrontendType.ADMIN: [
MenuSectionDefinition(
id="storeOps",
label_key="customers.menu.store_operations",
id="userManagement",
label_key="customers.menu.user_management",
icon="user-group",
order=40,
order=10,
items=[
MenuItemDefinition(
id="customers",
label_key="customers.menu.customers",
icon="user-group",
route="/admin/customers",
order=20,
order=40,
),
],
),

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,35 @@
"""customers 002 - drop order stats columns (moved to orders module)
Revision ID: customers_002
Revises: customers_001
Create Date: 2026-03-07
"""
import sqlalchemy as sa
from alembic import op
revision = "customers_002"
down_revision = "customers_001"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_column("customers", "total_orders")
op.drop_column("customers", "total_spent")
op.drop_column("customers", "last_order_date")
def downgrade() -> None:
op.add_column(
"customers",
sa.Column("last_order_date", sa.DateTime(), nullable=True),
)
op.add_column(
"customers",
sa.Column("total_spent", sa.Numeric(10, 2), nullable=True, server_default="0"),
)
op.add_column(
"customers",
sa.Column("total_orders", sa.Integer(), nullable=True, server_default="0"),
)

View File

@@ -10,10 +10,8 @@ from sqlalchemy import (
JSON,
Boolean,
Column,
DateTime,
ForeignKey,
Integer,
Numeric,
String,
)
from sqlalchemy.orm import relationship
@@ -41,9 +39,6 @@ class Customer(Base, TimestampMixin):
) # Store-specific ID
preferences = Column(JSON, default=dict)
marketing_consent = Column(Boolean, default=False)
last_order_date = Column(DateTime)
total_orders = Column(Integer, default=0)
total_spent = Column(Numeric(10, 2), default=0)
is_active = Column(Boolean, default=True, nullable=False)
# Language preference (NULL = use store storefront_language default)

View File

@@ -44,3 +44,25 @@ async def store_customers_page(
"customers/store/customers.html",
get_store_context(request, db, current_user, store_code),
)
@router.get(
"/customers/{customer_id}", response_class=HTMLResponse, include_in_schema=False
)
async def store_customer_detail_page(
request: Request,
customer_id: int,
store_code: str = Depends(get_resolved_store_code),
current_user: User = Depends(require_store_page_permission("customers.view")),
db: Session = Depends(get_db),
):
"""
Render customer detail page.
JavaScript loads customer profile and order stats via API.
"""
context = get_store_context(request, db, current_user, store_code)
context["customer_id"] = customer_id
return templates.TemplateResponse(
"customers/store/customer-detail.html",
context,
)

View File

@@ -7,7 +7,6 @@ avoiding direct database model imports in the API layer.
"""
from datetime import datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict
@@ -45,11 +44,6 @@ class CustomerContext(BaseModel):
marketing_consent: bool = False
preferred_language: str | None = None
# Stats (for order placement)
total_orders: int = 0
total_spent: Decimal = Decimal("0.00")
last_order_date: datetime | None = None
# Status
is_active: bool = True
@@ -89,9 +83,6 @@ class CustomerContext(BaseModel):
phone=customer.phone,
marketing_consent=customer.marketing_consent,
preferred_language=customer.preferred_language,
total_orders=customer.total_orders or 0,
total_spent=customer.total_spent or Decimal("0.00"),
last_order_date=customer.last_order_date,
is_active=customer.is_active,
created_at=customer.created_at,
updated_at=customer.updated_at,

View File

@@ -111,9 +111,6 @@ class CustomerResponse(BaseModel):
customer_number: str
marketing_consent: bool
preferred_language: str | None
last_order_date: datetime | None
total_orders: int
total_spent: Decimal
is_active: bool
created_at: datetime
updated_at: datetime
@@ -291,10 +288,6 @@ class CustomerStatisticsResponse(BaseModel):
total: int = 0
active: int = 0
inactive: int = 0
with_orders: int = 0
total_spent: float = 0.0
total_orders: int = 0
avg_order_value: float = 0.0
# ============================================================================
@@ -314,9 +307,6 @@ class AdminCustomerItem(BaseModel):
customer_number: str
marketing_consent: bool = False
preferred_language: str | None = None
last_order_date: datetime | None = None
total_orders: int = 0
total_spent: float = 0.0
is_active: bool = True
created_at: datetime
updated_at: datetime

View File

@@ -8,7 +8,6 @@ Handles customer operations for admin users across all stores.
import logging
from typing import Any
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.modules.customers.exceptions import CustomerNotFoundException
@@ -98,9 +97,6 @@ class AdminCustomerService:
"customer_number": customer.customer_number,
"marketing_consent": customer.marketing_consent,
"preferred_language": customer.preferred_language,
"last_order_date": customer.last_order_date,
"total_orders": customer.total_orders,
"total_spent": float(customer.total_spent) if customer.total_spent else 0,
"is_active": customer.is_active,
"created_at": customer.created_at,
"updated_at": customer.updated_at,
@@ -134,25 +130,11 @@ class AdminCustomerService:
total = query.count()
active = query.filter(Customer.is_active == True).count() # noqa: E712
inactive = query.filter(Customer.is_active == False).count() # noqa: E712
with_orders = query.filter(Customer.total_orders > 0).count()
# Total spent across all customers
total_spent_result = query.with_entities(func.sum(Customer.total_spent)).scalar()
total_spent = float(total_spent_result) if total_spent_result else 0
# Average order value
total_orders_result = query.with_entities(func.sum(Customer.total_orders)).scalar()
total_orders = int(total_orders_result) if total_orders_result else 0
avg_order_value = total_spent / total_orders if total_orders > 0 else 0
return {
"total": total,
"active": active,
"inactive": inactive,
"with_orders": with_orders,
"total_spent": total_spent,
"total_orders": total_orders,
"avg_order_value": round(avg_order_value, 2),
}
def get_customer(
@@ -195,9 +177,6 @@ class AdminCustomerService:
"customer_number": customer.customer_number,
"marketing_consent": customer.marketing_consent,
"preferred_language": customer.preferred_language,
"last_order_date": customer.last_order_date,
"total_orders": customer.total_orders,
"total_spent": float(customer.total_spent) if customer.total_spent else 0,
"is_active": customer.is_active,
"created_at": customer.created_at,
"updated_at": customer.updated_at,

View File

@@ -431,26 +431,6 @@ class CustomerService:
return customer
def update_customer_stats(
self, db: Session, customer_id: int, order_total: float
) -> None:
"""
Update customer statistics after order.
Args:
db: Database session
customer_id: Customer ID
order_total: Order total amount
"""
customer = db.query(Customer).filter(Customer.id == customer_id).first()
if customer:
customer.total_orders += 1
customer.total_spent += order_total
customer.last_order_date = datetime.utcnow()
logger.debug(f"Updated stats for customer {customer.email}")
def _generate_customer_number(
self, db: Session, store_id: int, store_code: str
) -> str:

View File

@@ -27,11 +27,7 @@ function adminCustomers() {
stats: {
total: 0,
active: 0,
inactive: 0,
with_orders: 0,
total_spent: 0,
total_orders: 0,
avg_order_value: 0
inactive: 0
},
// Pagination (standard structure matching pagination macro)
@@ -375,17 +371,6 @@ function adminCustomers() {
}
},
/**
* Format currency for display
*/
formatCurrency(amount) {
if (amount == null) return '-';
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
}).format(amount);
},
/**
* Format date for display
*/

View File

@@ -0,0 +1,150 @@
// app/modules/customers/static/store/js/customer-detail.js
/**
* Store customer detail page logic.
* Loads customer profile, order stats, and recent orders from existing APIs.
*/
const customerDetailLog = window.LogConfig?.createLogger('customerDetail') || console;
function storeCustomerDetail() {
return {
// Inherit base layout state
...data(),
// Page identifier
currentPage: 'customers',
// Loading states
loading: true,
error: '',
// Data
customerId: window.customerDetailData?.customerId,
customer: null,
orderStats: {
total_orders: 0,
total_spent_cents: 0,
last_order_date: null,
first_order_date: null
},
recentOrders: [],
// Computed
get customerName() {
if (this.customer?.first_name && this.customer?.last_name) {
return `${this.customer.first_name} ${this.customer.last_name}`;
}
return this.customer?.email || 'Unknown';
},
async init() {
try {
// Load i18n translations
await I18n.loadModule('customers');
customerDetailLog.info('Customer detail init, id:', this.customerId);
// Call parent init to set storeCode from URL
const parentInit = data().init;
if (parentInit) {
await parentInit.call(this);
}
// Load all data in parallel
await Promise.all([
this.loadCustomer(),
this.loadOrderStats(),
this.loadRecentOrders()
]);
customerDetailLog.info('Customer detail loaded');
} catch (error) {
customerDetailLog.error('Init failed:', error);
this.error = 'Failed to load customer details';
} finally {
this.loading = false;
}
},
/**
* Load customer profile
*/
async loadCustomer() {
try {
const response = await apiClient.get(`/store/customers/${this.customerId}`);
this.customer = response;
} catch (error) {
customerDetailLog.error('Failed to load customer:', error);
this.error = error.message || 'Customer not found';
}
},
/**
* Load order statistics from orders module
*/
async loadOrderStats() {
try {
const response = await apiClient.get(`/store/customers/${this.customerId}/order-stats`);
this.orderStats = response;
} catch (error) {
customerDetailLog.warn('Failed to load order stats:', error);
// Non-fatal — page still works without stats
}
},
/**
* Load recent orders from orders module
*/
async loadRecentOrders() {
try {
const response = await apiClient.get(`/store/customers/${this.customerId}/orders?limit=5`);
this.recentOrders = response.orders || [];
} catch (error) {
customerDetailLog.warn('Failed to load recent orders:', error);
// Non-fatal
}
},
/**
* Get customer initials for avatar
*/
getInitials() {
const first = this.customer?.first_name || '';
const last = this.customer?.last_name || '';
return (first.charAt(0) + last.charAt(0)).toUpperCase() || '?';
},
/**
* Navigate to send message
*/
messageCustomer() {
window.location.href = `/store/${this.storeCode}/messages?customer=${this.customerId}`;
},
/**
* Format date for display
*/
formatDate(dateStr) {
if (!dateStr) return '-';
const locale = window.STORE_CONFIG?.locale || 'en-GB';
return new Date(dateStr).toLocaleDateString(locale, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
},
/**
* Format price (cents to currency)
*/
formatPrice(cents) {
if (!cents && cents !== 0) return '-';
const locale = window.STORE_CONFIG?.locale || 'en-GB';
const currency = window.STORE_CONFIG?.currency || 'EUR';
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency
}).format(cents / 100);
}
};
}

View File

@@ -48,9 +48,7 @@ function storeCustomers() {
// Modal states
showDetailModal: false,
showOrdersModal: false,
selectedCustomer: null,
customerOrders: [],
// Debounce timer
searchTimeout: null,
@@ -227,25 +225,6 @@ function storeCustomers() {
}
},
/**
* View customer orders
*/
async viewCustomerOrders(customer) {
this.loading = true;
try {
const response = await apiClient.get(`/store/customers/${customer.id}/orders`);
this.selectedCustomer = customer;
this.customerOrders = response.orders || [];
this.showOrdersModal = true;
storeCustomersLog.info('Loaded customer orders:', customer.id, this.customerOrders.length);
} catch (error) {
storeCustomersLog.error('Failed to load customer orders:', error);
Utils.showToast(error.message || I18n.t('customers.messages.failed_to_load_customer_orders'), 'error');
} finally {
this.loading = false;
}
},
/**
* Send message to customer
*/
@@ -275,19 +254,6 @@ function storeCustomers() {
});
},
/**
* Format price for display
*/
formatPrice(cents) {
if (!cents && cents !== 0) return '-';
const locale = window.STORE_CONFIG?.locale || 'en-GB';
const currency = window.STORE_CONFIG?.currency || 'EUR';
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency
}).format(cents / 100);
},
/**
* Pagination: Previous page
*/

View File

@@ -29,7 +29,7 @@
{{ error_state('Error loading customers') }}
<!-- Stats Cards -->
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2">
<!-- Card: Total Customers -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
@@ -59,36 +59,6 @@
</p>
</div>
</div>
<!-- Card: With Orders -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
<span x-html="$icon('shopping-bag', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
With Orders
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.with_orders || 0">
0
</p>
</div>
</div>
<!-- Card: Total Spent -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
<span x-html="$icon('currency-euro', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Total Revenue
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatCurrency(stats.total_spent || 0)">
0
</p>
</div>
</div>
</div>
<!-- Search and Filters -->
@@ -134,8 +104,6 @@
<th class="px-4 py-3">Customer</th>
<th class="px-4 py-3">Store</th>
<th class="px-4 py-3">Customer #</th>
<th class="px-4 py-3">Orders</th>
<th class="px-4 py-3">Total Spent</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Joined</th>
<th class="px-4 py-3">Actions</th>
@@ -145,7 +113,7 @@
<!-- Loading state -->
<template x-if="loadingCustomers && customers.length === 0">
<tr>
<td colspan="8" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<td colspan="6" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('spinner', 'w-6 h-6 mx-auto mb-2')"></span>
<p>Loading customers...</p>
</td>
@@ -155,7 +123,7 @@
<!-- Empty state -->
<template x-if="!loadingCustomers && customers.length === 0">
<tr>
<td colspan="8" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<td colspan="6" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('user-group', 'w-12 h-12 mx-auto mb-2 text-gray-300')"></span>
<p class="font-medium">No customers found</p>
<p class="text-sm mt-1">Try adjusting your search or filters</p>
@@ -189,16 +157,6 @@
<span class="font-mono text-xs" x-text="customer.customer_number"></span>
</td>
<!-- Orders -->
<td class="px-4 py-3 text-sm">
<span x-text="customer.total_orders || 0"></span>
</td>
<!-- Total Spent -->
<td class="px-4 py-3 text-sm">
<span x-text="formatCurrency(customer.total_spent || 0)"></span>
</td>
<!-- Status -->
<td class="px-4 py-3 text-xs">
<span

View File

@@ -0,0 +1,178 @@
{# app/templates/store/customer-detail.html #}
{% extends "store/base.html" %}
{% from 'shared/macros/headers.html' import page_header_flex %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% block title %}Customer Details{% endblock %}
{% block alpine_data %}storeCustomerDetail(){% endblock %}
{% block content %}
<!-- Back Button -->
<div class="mb-6">
<a :href="`/store/${storeCode}/customers`"
class="inline-flex items-center text-sm text-gray-600 hover:text-purple-600 dark:text-gray-400 dark:hover:text-purple-400 mb-4">
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-1')"></span>
Back to Customers
</a>
</div>
{% call page_header_flex(title='Customer Details', subtitle='View customer profile and order history') %}
<div class="flex items-center gap-2" x-show="!loading && customer">
<span
class="px-3 py-1 text-sm font-semibold rounded-full"
:class="customer?.is_active
? 'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100'
: 'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100'"
x-text="customer?.is_active ? 'Active' : 'Inactive'"
></span>
</div>
{% endcall %}
{{ loading_state('Loading customer details...') }}
{{ error_state('Error loading customer') }}
<!-- Main Content -->
<div x-show="!loading && !error && customer" class="grid gap-6 lg:grid-cols-3">
<!-- Left Column: Profile -->
<div class="lg:col-span-2 space-y-6">
<!-- Profile Card -->
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Profile</h3>
</div>
<div class="p-6">
<div class="flex items-center mb-6">
<div class="w-16 h-16 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center mr-4">
<span class="text-xl font-semibold text-purple-600 dark:text-purple-300" x-text="getInitials()"></span>
</div>
<div>
<p class="text-lg font-semibold text-gray-800 dark:text-gray-200" x-text="customerName"></p>
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="customer?.email"></p>
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase">Customer #</p>
<p class="text-sm font-mono font-medium text-gray-700 dark:text-gray-200" x-text="customer?.customer_number || '-'"></p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase">Phone</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-200" x-text="customer?.phone || '-'"></p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase">Joined</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-200" x-text="formatDate(customer?.created_at)"></p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase">Language</p>
<p class="text-sm font-medium text-gray-700 dark:text-gray-200" x-text="(customer?.preferred_language || 'Default').toUpperCase()"></p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 uppercase">Marketing</p>
<p class="text-sm font-medium" :class="customer?.marketing_consent ? 'text-green-600' : 'text-gray-500'" x-text="customer?.marketing_consent ? 'Opted in' : 'Opted out'"></p>
</div>
</div>
</div>
</div>
<!-- Recent Orders -->
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Recent Orders</h3>
<a :href="`/store/${storeCode}/orders?customer_id=${customerId}`"
class="text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300"
x-show="recentOrders.length > 0">
View All Orders
<span x-html="$icon('arrow-right', 'w-4 h-4 inline ml-1')"></span>
</a>
</div>
<div class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="order in recentOrders" :key="order.id">
<a :href="`/store/${storeCode}/orders/${order.id}`" class="flex items-center justify-between p-4 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<div>
<p class="font-mono font-semibold text-gray-800 dark:text-gray-200" x-text="order.order_number || `#${order.id}`"></p>
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="formatDate(order.created_at || order.order_date)"></p>
</div>
<div class="text-right">
<p class="font-semibold text-gray-800 dark:text-gray-200" x-text="formatPrice(order.total_amount_cents)"></p>
<span
class="text-xs px-2 py-1 font-semibold leading-tight rounded-full"
:class="{
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': order.status === 'delivered' || order.status === 'completed',
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': order.status === 'pending',
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': order.status === 'processing' || order.status === 'shipped',
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': order.status === 'cancelled',
'text-gray-700 bg-gray-100 dark:bg-gray-700 dark:text-gray-100': !['delivered','completed','pending','processing','shipped','cancelled'].includes(order.status)
}"
x-text="order.status"
></span>
</div>
</a>
</template>
<div x-show="recentOrders.length === 0" class="p-6 text-center text-gray-500 dark:text-gray-400">
<span x-html="$icon('shopping-bag', 'w-8 h-8 mx-auto mb-2 text-gray-300')"></span>
<p>No orders yet</p>
</div>
</div>
</div>
</div>
<!-- Right Column: Order Stats & Actions -->
<div class="space-y-6">
<!-- Order Stats Cards -->
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Order Statistics</h3>
</div>
<div class="p-6 space-y-4">
<div class="flex justify-between items-center">
<span class="text-sm text-gray-500 dark:text-gray-400">Total Orders</span>
<span class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="orderStats.total_orders || 0"></span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-gray-500 dark:text-gray-400">Total Spent</span>
<span class="text-lg font-semibold text-purple-600 dark:text-purple-400" x-text="formatPrice(orderStats.total_spent_cents || 0)"></span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-gray-500 dark:text-gray-400">Avg Order Value</span>
<span class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatPrice(orderStats.total_orders ? Math.round(orderStats.total_spent_cents / orderStats.total_orders) : 0)"></span>
</div>
<div class="flex justify-between items-center">
<span class="text-sm text-gray-500 dark:text-gray-400">Last Order</span>
<span class="text-sm font-medium text-gray-700 dark:text-gray-200" x-text="orderStats.last_order_date ? formatDate(orderStats.last_order_date) : 'Never'"></span>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Actions</h3>
</div>
<div class="p-4 space-y-2">
<a :href="`/store/${storeCode}/orders?customer_id=${customerId}`"
class="w-full flex items-center justify-center px-4 py-2 text-sm font-medium text-purple-600 bg-purple-100 rounded-lg hover:bg-purple-200 dark:bg-purple-900 dark:text-purple-300 dark:hover:bg-purple-800">
<span x-html="$icon('shopping-bag', 'w-4 h-4 mr-2')"></span>
View All Orders
</a>
<button
@click="messageCustomer()"
class="w-full flex items-center justify-center px-4 py-2 text-sm font-medium text-green-600 bg-green-100 rounded-lg hover:bg-green-200 dark:bg-green-900 dark:text-green-300 dark:hover:bg-green-800">
<span x-html="$icon('chat-bubble-left-right', 'w-4 h-4 mr-2')"></span>
Send Message
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
window.customerDetailData = {
customerId: {{ customer_id }}
};
</script>
<script defer src="{{ url_for('customers_static', path='store/js/customer-detail.js') }}"></script>
{% endblock %}

View File

@@ -107,7 +107,6 @@
<th class="px-4 py-3">Customer</th>
<th class="px-4 py-3">Email</th>
<th class="px-4 py-3">Joined</th>
<th class="px-4 py-3">Orders</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
@@ -130,8 +129,6 @@
<td class="px-4 py-3 text-sm" x-text="customer.email || '-'"></td>
<!-- Joined -->
<td class="px-4 py-3 text-sm" x-text="formatDate(customer.created_at)"></td>
<!-- Orders -->
<td class="px-4 py-3 text-sm font-semibold" x-text="customer.order_count || 0"></td>
<!-- Actions -->
<td class="px-4 py-3">
<div class="flex items-center space-x-2 text-sm">
@@ -142,13 +139,6 @@
>
<span x-html="$icon('eye', 'w-5 h-5')"></span>
</button>
<button
@click="viewCustomerOrders(customer)"
class="p-1 text-gray-500 hover:text-blue-600 dark:text-gray-400 dark:hover:text-blue-400"
title="View Orders"
>
<span x-html="$icon('document-text', 'w-5 h-5')"></span>
</button>
<button
@click="messageCustomer(customer)"
class="p-1 text-gray-500 hover:text-green-600 dark:text-gray-400 dark:hover:text-green-400"
@@ -162,7 +152,7 @@
</template>
<!-- Empty State -->
<tr x-show="customers.length === 0">
<td colspan="5" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<td colspan="4" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<div class="flex flex-col items-center">
<span x-html="$icon('users', 'w-12 h-12 text-gray-300 dark:text-gray-600 mb-4')"></span>
<p class="text-lg font-medium">No customers found</p>
@@ -199,12 +189,12 @@
<p class="font-medium text-gray-800 dark:text-gray-200" x-text="formatDate(selectedCustomer?.created_at)"></p>
</div>
<div>
<p class="text-gray-500 dark:text-gray-400">Total Orders</p>
<p class="font-medium text-gray-800 dark:text-gray-200" x-text="selectedCustomer?.order_count || 0"></p>
<p class="text-gray-500 dark:text-gray-400">Customer #</p>
<p class="font-medium font-mono text-gray-800 dark:text-gray-200" x-text="selectedCustomer?.customer_number || '-'"></p>
</div>
<div>
<p class="text-gray-500 dark:text-gray-400">Total Spent</p>
<p class="font-medium text-gray-800 dark:text-gray-200" x-text="formatPrice(selectedCustomer?.total_spent || 0)"></p>
<p class="text-gray-500 dark:text-gray-400">Status</p>
<p class="font-medium" :class="selectedCustomer?.is_active ? 'text-green-600' : 'text-red-600'" x-text="selectedCustomer?.is_active ? 'Active' : 'Inactive'"></p>
</div>
</div>
</div>
@@ -212,55 +202,11 @@
<button @click="showDetailModal = false" class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200">
Close
</button>
<button @click="messageCustomer(selectedCustomer); showDetailModal = false" class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
Send Message
</button>
<a :href="`/store/${storeCode}/customers/${selectedCustomer?.id}`" class="px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700">
View Full Profile
</a>
</div>
{% endcall %}
<!-- Customer Orders Modal -->
<div x-show="showOrdersModal" x-cloak class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black bg-opacity-50">
<div class="w-full max-w-2xl bg-white rounded-lg shadow-xl dark:bg-gray-800" @click.away="showOrdersModal = false">
<div class="flex items-center justify-between p-4 border-b dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
Orders for <span x-text="`${selectedCustomer?.first_name || ''} ${selectedCustomer?.last_name || ''}`.trim()"></span>
</h3>
<button @click="showOrdersModal = false" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
<span x-html="$icon('x', 'w-5 h-5')"></span>
</button>
</div>
<div class="p-4 max-h-96 overflow-y-auto">
<template x-if="customerOrders.length === 0">
<p class="text-center text-gray-500 dark:text-gray-400 py-8">No orders found for this customer</p>
</template>
<template x-for="order in customerOrders" :key="order.id">
<div class="flex items-center justify-between p-3 border-b dark:border-gray-700 last:border-0">
<div>
<p class="font-mono font-semibold text-gray-800 dark:text-gray-200" x-text="order.order_number || `#${order.id}`"></p>
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="formatDate(order.created_at)"></p>
</div>
<div class="text-right">
<p class="font-semibold text-gray-800 dark:text-gray-200" x-text="formatPrice(order.total)"></p>
<span
class="text-xs px-2 py-1 font-semibold leading-tight rounded-full"
:class="{
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': order.status === 'completed',
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': order.status === 'pending',
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': order.status === 'processing'
}"
x-text="order.status"
></span>
</div>
</div>
</template>
</div>
<div class="flex justify-end p-4 border-t dark:border-gray-700">
<button @click="showOrdersModal = false" class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200">
Close
</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}

View File

@@ -1,11 +1,11 @@
{# app/templates/storefront/account/forgot-password.html #}
{# standalone #}
<!DOCTYPE html>
<html :class="{ 'dark': dark }" x-data="forgotPassword()" lang="en">
<html :class="{ 'dark': dark }" x-data="forgotPassword()" lang="{{ current_language|default('fr') }}">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Forgot Password - {{ store.name }}</title>
<title>{{ _("auth.forgot_password") }} - {{ store.name }}</title>
<!-- Fonts: Local fallback + Google Fonts -->
<link href="/static/shared/fonts/inter.css" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
@@ -57,7 +57,6 @@
<div class="text-6xl mb-4">🔐</div>
{% endif %}
<h2 class="text-2xl font-bold text-white mb-2">{{ store.name }}</h2>
<p class="text-white opacity-90">Reset your password</p>
</div>
</div>
@@ -68,11 +67,11 @@
<template x-if="!emailSent">
<div>
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
Forgot Password
{{ _("auth.reset_password") }}
</h1>
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">
Enter your email address and we'll send you a link to reset your password.
{{ _("auth.reset_password_desc") }}
</p>
<!-- Error Message -->
@@ -84,14 +83,14 @@
<!-- Forgot Password Form -->
<form @submit.prevent="handleSubmit">
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">Email Address</span>
<span class="text-gray-700 dark:text-gray-400">{{ _("common.email") }}</span>
<input x-model="email"
:disabled="loading"
@input="clearErrors"
type="email"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus-primary focus:outline-none dark:text-gray-300 form-input rounded-md border-gray-300"
:class="{ 'border-red-600': errors.email }"
placeholder="your@email.com"
placeholder="{{ _('auth.email_placeholder') }}"
autocomplete="email"
required />
<span x-show="errors.email" x-text="errors.email"
@@ -100,10 +99,13 @@
<button type="submit" :disabled="loading"
class="btn-primary-theme block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 border border-transparent rounded-lg focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
<span x-show="!loading">Send Reset Link</span>
<span x-show="!loading">{{ _("auth.send_reset_link") }}</span>
<span x-show="loading" class="flex items-center justify-center">
<span class="inline w-4 h-4 mr-2" x-html="$icon('spinner', 'w-4 h-4 animate-spin')"></span>
Sending...
<svg class="inline w-4 h-4 mr-2 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ _("auth.sending") }}
</span>
</button>
</form>
@@ -114,24 +116,25 @@
<template x-if="emailSent">
<div class="text-center">
<div class="flex items-center justify-center w-16 h-16 mx-auto mb-4 rounded-full bg-green-100 dark:bg-green-900">
<span class="w-8 h-8 text-green-600 dark:text-green-400" x-html="$icon('check', 'w-8 h-8')"></span>
<svg class="w-8 h-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
Check Your Email
{{ _("auth.check_email") }}
</h1>
<p class="mb-6 text-sm text-gray-600 dark:text-gray-400">
We've sent a password reset link to <strong x-text="email"></strong>.
Please check your inbox and click the link to reset your password.
{{ _("auth.reset_link_sent") }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
Didn't receive the email? Check your spam folder or
{{ _("auth.didnt_receive_email") }}
<button @click="emailSent = false"
class="font-medium hover:underline"
style="color: var(--color-primary);">
try again
{{ _("auth.try_again") }}
</button>
</p>
</div>
@@ -140,19 +143,34 @@
<hr class="my-8" />
<p class="mt-4 text-center">
<span class="text-sm text-gray-600 dark:text-gray-400">Remember your password?</span>
<span class="text-sm text-gray-600 dark:text-gray-400">{{ _("auth.remember_password") }}</span>
<a class="text-sm font-medium hover:underline ml-1"
style="color: var(--color-primary);"
href="{{ base_url }}account/login">
Sign in
{{ _("auth.sign_in") }}
</a>
</p>
<p class="mt-2 text-center">
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
href="{{ base_url }}">
← Continue shopping
&larr; {{ _("auth.continue_shopping") }}
</a>
</p>
<!-- Language selector -->
<div class="flex items-center justify-center gap-2 mt-6"
x-data='languageSelector("{{ request.state.language|default("fr") }}", {{ ["en", "fr", "de", "lb"]|tojson }})'>
<template x-for="lang in languages" :key="lang">
<button
@click="setLanguage(lang)"
class="px-2.5 py-1 text-sm font-semibold rounded-md transition-all"
:class="currentLang === lang
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 ring-1 ring-purple-300 dark:ring-purple-700'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'"
x-text="lang.toUpperCase()"
></button>
</template>
</div>
</div>
</div>
</div>
@@ -164,6 +182,22 @@
<!-- Forgot Password Logic -->
<script>
function languageSelector(currentLang, enabledLanguages) {
return {
currentLang: currentLang || 'fr',
languages: enabledLanguages || ['en', 'fr', 'de', 'lb'],
async setLanguage(lang) {
if (lang === this.currentLang) return;
await fetch('/api/v1/platform/language/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ language: lang }),
});
window.location.reload();
},
};
}
function forgotPassword() {
return {
// Data

View File

@@ -64,7 +64,7 @@
<div class="flex items-center justify-center p-6 sm:p-12 md:w-1/2">
<div class="w-full">
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
Customer Login
{{ _("auth.customer_login") }}
</h1>
<!-- Success Message (after registration) -->
@@ -82,14 +82,14 @@
<!-- Login Form -->
<form @submit.prevent="handleLogin">
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">Email Address</span>
<span class="text-gray-700 dark:text-gray-400">{{ _("common.email") }}</span>
<input x-model="credentials.email"
:disabled="loading"
@input="clearAllErrors"
type="email"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus-primary focus:outline-none dark:text-gray-300 form-input rounded-md border-gray-300"
:class="{ 'border-red-600': errors.email }"
placeholder="your@email.com"
placeholder="{{ _('auth.email_placeholder') }}"
autocomplete="email"
required />
<span x-show="errors.email" x-text="errors.email"
@@ -97,7 +97,7 @@
</label>
<label class="block mt-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">Password</span>
<span class="text-gray-700 dark:text-gray-400">{{ _("auth.password") }}</span>
<div class="relative">
<input x-model="credentials.password"
:disabled="loading"
@@ -105,7 +105,7 @@
:type="showPassword ? 'text' : 'password'"
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus-primary focus:outline-none dark:text-gray-300 form-input rounded-md border-gray-300"
:class="{ 'border-red-600': errors.password }"
placeholder="Enter your password"
placeholder="{{ _('auth.password_placeholder') }}"
autocomplete="current-password"
required />
<button type="button"
@@ -125,21 +125,21 @@
x-model="rememberMe"
class="form-checkbox focus-primary focus:outline-none"
style="color: var(--color-primary);">
<span class="ml-2 text-gray-700 dark:text-gray-400">Remember me</span>
<span class="ml-2 text-gray-700 dark:text-gray-400">{{ _("auth.remember_me") }}</span>
</label>
<a href="{{ base_url }}account/forgot-password"
class="text-sm font-medium hover:underline"
style="color: var(--color-primary);">
Forgot password?
{{ _("auth.forgot_password") }}
</a>
</div>
<button type="submit" :disabled="loading"
class="btn-primary-theme block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 border border-transparent rounded-lg focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
<span x-show="!loading">Sign in</span>
<span x-show="!loading">{{ _("auth.sign_in") }}</span>
<span x-show="loading" class="flex items-center justify-center">
<span class="inline w-4 h-4 mr-2" x-html="$icon('spinner', 'w-4 h-4 animate-spin')"></span>
Signing in...
{{ _("auth.signing_in") }}
</span>
</button>
</form>
@@ -147,19 +147,34 @@
<hr class="my-8" />
<p class="mt-4 text-center">
<span class="text-sm text-gray-600 dark:text-gray-400">Don't have an account?</span>
<span class="text-sm text-gray-600 dark:text-gray-400">{{ _("auth.no_account") }}</span>
<a class="text-sm font-medium hover:underline ml-1"
style="color: var(--color-primary);"
href="{{ base_url }}account/register">
Create an account
{{ _("auth.create_account") }}
</a>
</p>
<p class="mt-2 text-center">
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
href="{{ base_url }}">
← Continue shopping
&larr; {{ _("auth.continue_shopping") }}
</a>
</p>
<!-- Language selector (always show all platform languages on login page) -->
<div class="flex items-center justify-center gap-2 mt-6"
x-data='languageSelector("{{ request.state.language|default("fr") }}", {{ ["en", "fr", "de", "lb"]|tojson }})'>
<template x-for="lang in languages" :key="lang">
<button
@click="setLanguage(lang)"
class="px-2.5 py-1 text-sm font-semibold rounded-md transition-all"
:class="currentLang === lang
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 ring-1 ring-purple-300 dark:ring-purple-700'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'"
x-text="lang.toUpperCase()"
></button>
</template>
</div>
</div>
</div>
</div>
@@ -171,6 +186,22 @@
<!-- Login Logic -->
<script>
function languageSelector(currentLang, enabledLanguages) {
return {
currentLang: currentLang || 'fr',
languages: enabledLanguages || ['en', 'fr', 'de', 'lb'],
async setLanguage(lang) {
if (lang === this.currentLang) return;
await fetch('/api/v1/platform/language/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ language: lang }),
});
window.location.reload();
},
};
}
function customerLogin() {
return {
// Data

View File

@@ -65,7 +65,7 @@
<div class="flex items-center justify-center p-6 sm:p-12 md:w-1/2">
<div class="w-full">
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
Create Account
{{ _("auth.create_account_title") }}
</h1>
<!-- Success Message -->
@@ -85,7 +85,7 @@
<!-- First Name -->
<label class="block text-sm">
<span class="text-gray-700 dark:text-gray-400">
First Name <span class="text-red-600">*</span>
{{ _("auth.first_name") }} <span class="text-red-600">*</span>
</span>
<input x-model="formData.first_name"
:disabled="loading"
@@ -102,7 +102,7 @@
<!-- Last Name -->
<label class="block mt-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">
Last Name <span class="text-red-600">*</span>
{{ _("auth.last_name") }} <span class="text-red-600">*</span>
</span>
<input x-model="formData.last_name"
:disabled="loading"
@@ -119,7 +119,7 @@
<!-- Email -->
<label class="block mt-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">
Email Address <span class="text-red-600">*</span>
{{ _("common.email") }} <span class="text-red-600">*</span>
</span>
<input x-model="formData.email"
:disabled="loading"
@@ -136,7 +136,7 @@
<!-- Phone (Optional) -->
<label class="block mt-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">Phone Number</span>
<span class="text-gray-700 dark:text-gray-400">{{ _("auth.phone_number") }}</span>
<input x-model="formData.phone"
:disabled="loading"
type="tel"
@@ -147,7 +147,7 @@
<!-- Password -->
<label class="block mt-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">
Password <span class="text-red-600">*</span>
{{ _("auth.password") }} <span class="text-red-600">*</span>
</span>
<div class="relative">
<input x-model="formData.password"
@@ -166,7 +166,7 @@
</button>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Must contain at least 8 characters, one letter, and one number
{{ _("auth.password_requirements") }}
</p>
<span x-show="errors.password" x-text="errors.password"
class="text-xs text-red-600 dark:text-red-400 mt-1"></span>
@@ -175,7 +175,7 @@
<!-- Confirm Password -->
<label class="block mt-4 text-sm">
<span class="text-gray-700 dark:text-gray-400">
Confirm Password <span class="text-red-600">*</span>
{{ _("auth.confirm_password") }} <span class="text-red-600">*</span>
</span>
<input x-model="confirmPassword"
:disabled="loading"
@@ -198,16 +198,16 @@
class="form-checkbox focus-primary focus:outline-none mt-1"
style="color: var(--color-primary);">
<label for="marketingConsent" class="ml-2 text-sm text-gray-600 dark:text-gray-400">
I'd like to receive news and special offers
{{ _("auth.marketing_consent") }}
</label>
</div>
<button type="submit" :disabled="loading"
class="btn-primary-theme block w-full px-4 py-2 mt-6 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 border border-transparent rounded-lg focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
<span x-show="!loading">Create Account</span>
<span x-show="!loading">{{ _("auth.create_account_title") }}</span>
<span x-show="loading" class="flex items-center justify-center">
<span class="inline w-4 h-4 mr-2" x-html="$icon('spinner', 'w-4 h-4 animate-spin')"></span>
Creating account...
{{ _("auth.creating_account") }}
</span>
</button>
</form>
@@ -215,13 +215,28 @@
<hr class="my-8" />
<p class="mt-4 text-center">
<span class="text-sm text-gray-600 dark:text-gray-400">Already have an account?</span>
<span class="text-sm text-gray-600 dark:text-gray-400">{{ _("auth.already_have_account") }}</span>
<a class="text-sm font-medium hover:underline ml-1"
style="color: var(--color-primary);"
href="{{ base_url }}account/login">
Sign in instead
{{ _("auth.sign_in_instead") }}
</a>
</p>
<!-- Language selector (always show all platform languages on login/register pages) -->
<div class="flex items-center justify-center gap-2 mt-6"
x-data='languageSelector("{{ request.state.language|default("fr") }}", {{ ["en", "fr", "de", "lb"]|tojson }})'>
<template x-for="lang in languages" :key="lang">
<button
@click="setLanguage(lang)"
class="px-2.5 py-1 text-sm font-semibold rounded-md transition-all"
:class="currentLang === lang
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 ring-1 ring-purple-300 dark:ring-purple-700'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'"
x-text="lang.toUpperCase()"
></button>
</template>
</div>
</div>
</div>
</div>
@@ -233,6 +248,22 @@
<!-- Registration Logic -->
<script>
function languageSelector(currentLang, enabledLanguages) {
return {
currentLang: currentLang || 'fr',
languages: enabledLanguages || ['en', 'fr', 'de', 'lb'],
async setLanguage(lang) {
if (lang === this.currentLang) return;
await fetch('/api/v1/platform/language/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ language: lang }),
});
window.location.reload();
},
};
}
function customerRegistration() {
return {
// Data

View File

@@ -3,8 +3,6 @@
Unit tests for AdminCustomerService.
"""
from decimal import Decimal
import pytest
from app.modules.customers.exceptions import CustomerNotFoundException
@@ -18,16 +16,6 @@ def admin_customer_service():
return AdminCustomerService()
@pytest.fixture
def customer_with_orders(db, test_store, test_customer):
"""Create a customer with order data."""
test_customer.total_orders = 5
test_customer.total_spent = Decimal("250.00")
db.commit()
db.refresh(test_customer)
return test_customer
@pytest.fixture
def multiple_customers(db, test_store):
"""Create multiple customers for testing."""
@@ -41,8 +29,6 @@ def multiple_customers(db, test_store):
last_name=f"Last{i}",
customer_number=f"CUST-00{i}",
is_active=(i % 2 == 0), # Alternate active/inactive
total_orders=i,
total_spent=Decimal(str(i * 100)),
)
db.add(customer) # noqa: PERF006
customers.append(customer)
@@ -165,10 +151,6 @@ class TestAdminCustomerServiceStats:
assert stats["total"] == 0
assert stats["active"] == 0
assert stats["inactive"] == 0
assert stats["with_orders"] == 0
assert stats["total_spent"] == 0
assert stats["total_orders"] == 0
assert stats["avg_order_value"] == 0
def test_get_customer_stats_with_data(
self, db, admin_customer_service, multiple_customers
@@ -179,12 +161,6 @@ class TestAdminCustomerServiceStats:
assert stats["total"] == 5
assert stats["active"] == 3 # 0, 2, 4
assert stats["inactive"] == 2 # 1, 3
# with_orders = customers with total_orders > 0 (1, 2, 3, 4 = 4 customers)
assert stats["with_orders"] == 4
# total_spent = 0 + 100 + 200 + 300 + 400 = 1000
assert stats["total_spent"] == 1000.0
# total_orders = 0 + 1 + 2 + 3 + 4 = 10
assert stats["total_orders"] == 10
def test_get_customer_stats_by_store(
self, db, admin_customer_service, test_customer, test_store
@@ -194,16 +170,6 @@ class TestAdminCustomerServiceStats:
assert stats["total"] == 1
def test_get_customer_stats_avg_order_value(
self, db, admin_customer_service, customer_with_orders
):
"""Test average order value calculation."""
stats = admin_customer_service.get_customer_stats(db)
# total_spent = 250, total_orders = 5
# avg = 250 / 5 = 50
assert stats["avg_order_value"] == 50.0
@pytest.mark.unit
class TestAdminCustomerServiceGetCustomer:

View File

@@ -49,8 +49,6 @@ class TestCustomerModel:
assert customer.is_active is True # Default
assert customer.marketing_consent is False # Default
assert customer.total_orders == 0 # Default
assert customer.total_spent == 0 # Default
def test_customer_full_name_property(self, db, test_store):
"""Test Customer full_name computed property."""

View File

@@ -149,7 +149,6 @@ class TestCustomerResponseSchema:
def test_from_dict(self):
"""Test creating response from dict."""
from datetime import datetime
from decimal import Decimal
data = {
"id": 1,
@@ -161,9 +160,6 @@ class TestCustomerResponseSchema:
"customer_number": "CUST001",
"marketing_consent": False,
"preferred_language": "fr",
"last_order_date": None,
"total_orders": 5,
"total_spent": Decimal("500.00"),
"is_active": True,
"created_at": datetime.now(),
"updated_at": datetime.now(),
@@ -171,7 +167,7 @@ class TestCustomerResponseSchema:
response = CustomerResponse(**data)
assert response.id == 1
assert response.customer_number == "CUST001"
assert response.total_orders == 5
assert response.preferred_language == "fr"
@pytest.mark.unit

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

@@ -38,7 +38,6 @@ MANDATORY_MENU_ITEMS = {
"stores",
"admin-users",
"settings",
"my-menu", # Super admin menu config - must always be accessible
}),
FrontendType.STORE: frozenset({
"dashboard", # Default landing page after login

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

@@ -90,5 +90,11 @@
"view_rewards_desc": "Prämien und Einlösungen anzeigen",
"manage_rewards": "Prämien verwalten",
"manage_rewards_desc": "Treueprämien erstellen und verwalten"
},
"onboarding": {
"create_program": {
"title": "Treueprogramm erstellen",
"description": "Erstellen Sie Ihr erstes Stempel- oder Punkteprogramm"
}
}
}

View File

@@ -90,5 +90,11 @@
"statistics": "Statistics",
"overview": "Overview",
"settings": "Settings"
},
"onboarding": {
"create_program": {
"title": "Create a loyalty program",
"description": "Set up your first stamp or points program"
}
}
}

View File

@@ -90,5 +90,11 @@
"view_rewards_desc": "Voir les récompenses et les échanges",
"manage_rewards": "Gérer les récompenses",
"manage_rewards_desc": "Créer et gérer les récompenses de fidélité"
},
"onboarding": {
"create_program": {
"title": "Créer un programme de fidélité",
"description": "Créez votre premier programme de tampons ou de points"
}
}
}

View File

@@ -90,5 +90,11 @@
"view_rewards_desc": "Belounungen an Aléisunge kucken",
"manage_rewards": "Beloununge verwalten",
"manage_rewards_desc": "Treiheet-Belounungen erstellen a verwalten"
},
"onboarding": {
"create_program": {
"title": "Treieprogramm erstellen",
"description": "Erstellt Äert éischt Stempel- oder Punkteprogramm"
}
}
}

View File

@@ -26,8 +26,8 @@ class LoyaltyOnboardingProvider:
return [
OnboardingStepDefinition(
key="loyalty.create_program",
title_key="onboarding.loyalty.create_program.title",
description_key="onboarding.loyalty.create_program.description",
title_key="loyalty.onboarding.create_program.title",
description_key="loyalty.onboarding.create_program.description",
icon="gift",
route_template="/store/{store_code}/loyalty/programs",
order=300,

View File

@@ -32,6 +32,9 @@ function merchantLoyaltySettings() {
if (window._merchantLoyaltySettingsInitialized) return;
window._merchantLoyaltySettingsInitialized = true;
// Load sidebar menu (from base data())
this.loadMenuConfig();
await this.loadSettings();
loyaltySettingsLog.info('=== MERCHANT LOYALTY SETTINGS PAGE INITIALIZATION COMPLETE ===');
},

View File

@@ -147,6 +147,7 @@ marketplace_module = ModuleDefinition(
icon="download",
route="/store/{store_code}/marketplace",
order=30,
requires_permission="marketplace.view_integration",
),
],
),
@@ -162,6 +163,7 @@ marketplace_module = ModuleDefinition(
icon="external-link",
route="/store/{store_code}/letzshop",
order=20,
requires_permission="marketplace.view_integration",
),
],
),

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.

Some files were not shown because too many files have changed in this diff Show More