Compare commits
2 Commits
2287f4597d
...
a77a8a3a98
| Author | SHA1 | Date | |
|---|---|---|---|
| a77a8a3a98 | |||
| f141cc4e6a |
@@ -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
|
||||
|
||||
@@ -96,6 +96,7 @@ analytics_module = ModuleDefinition(
|
||||
icon="chart-bar",
|
||||
route="/store/{store_code}/analytics",
|
||||
order=20,
|
||||
requires_permission="analytics.view",
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
42
app/modules/analytics/docs/index.md
Normal file
42
app/modules/analytics/docs/index.md
Normal 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.
|
||||
138
app/modules/billing/docs/data-model.md
Normal file
138
app/modules/billing/docs/data-model.md
Normal 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
|
||||
434
app/modules/billing/docs/feature-gating.md
Normal file
434
app/modules/billing/docs/feature-gating.md
Normal 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
|
||||
74
app/modules/billing/docs/index.md
Normal file
74
app/modules/billing/docs/index.md
Normal 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
|
||||
617
app/modules/billing/docs/stripe-integration.md
Normal file
617
app/modules/billing/docs/stripe-integration.md
Normal 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.
|
||||
182
app/modules/billing/docs/subscription-system.md
Normal file
182
app/modules/billing/docs/subscription-system.md
Normal 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
|
||||
454
app/modules/billing/docs/subscription-workflow.md
Normal file
454
app/modules/billing/docs/subscription-workflow.md
Normal 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?
|
||||
135
app/modules/billing/docs/tier-management.md
Normal file
135
app/modules/billing/docs/tier-management.md
Normal 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
|
||||
@@ -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;
|
||||
|
||||
|
||||
41
app/modules/cart/docs/index.md
Normal file
41
app/modules/cart/docs/index.md
Normal 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.
|
||||
291
app/modules/catalog/docs/architecture.md
Normal file
291
app/modules/catalog/docs/architecture.md
Normal 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`
|
||||
105
app/modules/catalog/docs/data-model.md
Normal file
105
app/modules/catalog/docs/data-model.md
Normal 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
|
||||
57
app/modules/catalog/docs/index.md
Normal file
57
app/modules/catalog/docs/index.md
Normal 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
|
||||
41
app/modules/checkout/docs/index.md
Normal file
41
app/modules/checkout/docs/index.md
Normal 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.
|
||||
@@ -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)
|
||||
|
||||
604
app/modules/cms/docs/architecture.md
Normal file
604
app/modules/cms/docs/architecture.md
Normal 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
|
||||
115
app/modules/cms/docs/data-model.md
Normal file
115
app/modules/cms/docs/data-model.md
Normal 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
|
||||
287
app/modules/cms/docs/email-templates-guide.md
Normal file
287
app/modules/cms/docs/email-templates-guide.md
Normal 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.
|
||||
458
app/modules/cms/docs/email-templates.md
Normal file
458
app/modules/cms/docs/email-templates.md
Normal 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
|
||||
414
app/modules/cms/docs/implementation.md
Normal file
414
app/modules/cms/docs/implementation.md
Normal 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!
|
||||
61
app/modules/cms/docs/index.md
Normal file
61
app/modules/cms/docs/index.md
Normal 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
|
||||
182
app/modules/cms/docs/media-library.md
Normal file
182
app/modules/cms/docs/media-library.md
Normal 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
|
||||
33
app/modules/contracts/docs/index.md
Normal file
33
app/modules/contracts/docs/index.md
Normal 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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
44
app/modules/core/docs/index.md
Normal file
44
app/modules/core/docs/index.md
Normal 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.
|
||||
@@ -72,7 +72,6 @@
|
||||
"dashboard": "Dashboard",
|
||||
"platform_settings": "Plattform-Einstellungen",
|
||||
"general": "Allgemein",
|
||||
"my_menu": "Mein Menü",
|
||||
"account_settings": "Kontoeinstellungen",
|
||||
"profile": "Profil",
|
||||
"settings": "Einstellungen"
|
||||
|
||||
@@ -92,7 +92,6 @@
|
||||
"dashboard": "Dashboard",
|
||||
"platform_settings": "Platform Settings",
|
||||
"general": "General",
|
||||
"my_menu": "My Menu",
|
||||
"account_settings": "Account Settings",
|
||||
"profile": "Profile",
|
||||
"settings": "Settings"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.",
|
||||
)
|
||||
|
||||
# ========================================================================
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
47
app/modules/customers/docs/index.md
Normal file
47
app/modules/customers/docs/index.md
Normal 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.
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
150
app/modules/customers/static/store/js/customer-detail.js
Normal file
150
app/modules/customers/static/store/js/customer-detail.js
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
← {{ _("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
|
||||
|
||||
@@ -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
|
||||
← {{ _("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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
42
app/modules/dev_tools/docs/index.md
Normal file
42
app/modules/dev_tools/docs/index.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
49
app/modules/hosting/docs/index.md
Normal file
49
app/modules/hosting/docs/index.md
Normal 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
|
||||
502
app/modules/hosting/docs/user-journeys.md
Normal file
502
app/modules/hosting/docs/user-journeys.md
Normal 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.
|
||||
82
app/modules/inventory/docs/data-model.md
Normal file
82
app/modules/inventory/docs/data-model.md
Normal 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
|
||||
53
app/modules/inventory/docs/index.md
Normal file
53
app/modules/inventory/docs/index.md
Normal 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
|
||||
366
app/modules/inventory/docs/user-guide.md
Normal file
366
app/modules/inventory/docs/user-guide.md
Normal 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)
|
||||
264
app/modules/loyalty/docs/business-logic.md
Normal file
264
app/modules/loyalty/docs/business-logic.md
Normal 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.
|
||||
235
app/modules/loyalty/docs/data-model.md
Normal file
235
app/modules/loyalty/docs/data-model.md
Normal 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)`.
|
||||
110
app/modules/loyalty/docs/index.md
Normal file
110
app/modules/loyalty/docs/index.md
Normal 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
|
||||
387
app/modules/loyalty/docs/program-analysis.md
Normal file
387
app/modules/loyalty/docs/program-analysis.md
Normal 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.*
|
||||
670
app/modules/loyalty/docs/ui-design.md
Normal file
670
app/modules/loyalty/docs/ui-design.md
Normal 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*
|
||||
794
app/modules/loyalty/docs/user-journeys.md
Normal file
794
app/modules/loyalty/docs/user-journeys.md
Normal 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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ===');
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
261
app/modules/marketplace/docs/admin-guide.md
Normal file
261
app/modules/marketplace/docs/admin-guide.md
Normal 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
|
||||
322
app/modules/marketplace/docs/api.md
Normal file
322
app/modules/marketplace/docs/api.md
Normal 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.
|
||||
1345
app/modules/marketplace/docs/architecture.md
Normal file
1345
app/modules/marketplace/docs/architecture.md
Normal file
File diff suppressed because it is too large
Load Diff
297
app/modules/marketplace/docs/data-model.md
Normal file
297
app/modules/marketplace/docs/data-model.md
Normal 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 |
|
||||
601
app/modules/marketplace/docs/import-improvements.md
Normal file
601
app/modules/marketplace/docs/import-improvements.md
Normal 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 |
|
||||
73
app/modules/marketplace/docs/index.md
Normal file
73
app/modules/marketplace/docs/index.md
Normal 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
|
||||
BIN
app/modules/marketplace/docs/integration-guide.md
Normal file
BIN
app/modules/marketplace/docs/integration-guide.md
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user