From f141cc4e6a61b2131872849f7619fb755d11f4a8 Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Sun, 8 Mar 2026 23:38:37 +0100 Subject: [PATCH] docs: migrate module documentation to single source of truth Move 39 documentation files from top-level docs/ into each module's docs/ folder, accessible via symlinks from docs/modules/. Create data-model.md files for 10 modules with full schema documentation. Replace originals with redirect stubs. Remove empty guide stubs. Modules migrated: tenancy, billing, loyalty, marketplace, orders, messaging, cms, catalog, inventory, hosting, prospecting. Co-Authored-By: Claude Opus 4.6 --- app/modules/analytics/docs/index.md | 42 + app/modules/billing/docs/data-model.md | 138 ++ app/modules/billing/docs/feature-gating.md | 434 ++++++ app/modules/billing/docs/index.md | 74 + .../billing/docs/stripe-integration.md | 617 ++++++++ .../billing/docs/subscription-system.md | 182 +++ .../billing/docs/subscription-workflow.md | 454 ++++++ app/modules/billing/docs/tier-management.md | 135 ++ app/modules/cart/docs/index.md | 41 + app/modules/catalog/docs/architecture.md | 291 ++++ app/modules/catalog/docs/data-model.md | 105 ++ app/modules/catalog/docs/index.md | 57 + app/modules/checkout/docs/index.md | 41 + app/modules/cms/docs/architecture.md | 604 ++++++++ app/modules/cms/docs/data-model.md | 115 ++ app/modules/cms/docs/email-templates-guide.md | 287 ++++ app/modules/cms/docs/email-templates.md | 458 ++++++ app/modules/cms/docs/implementation.md | 414 +++++ app/modules/cms/docs/index.md | 61 + app/modules/cms/docs/media-library.md | 182 +++ app/modules/contracts/docs/index.md | 33 + app/modules/core/docs/index.md | 44 + app/modules/customers/docs/index.md | 47 + app/modules/dev_tools/docs/index.md | 42 + app/modules/hosting/docs/index.md | 49 + app/modules/hosting/docs/user-journeys.md | 502 ++++++ app/modules/inventory/docs/data-model.md | 82 + app/modules/inventory/docs/index.md | 53 + app/modules/inventory/docs/user-guide.md | 366 +++++ app/modules/loyalty/docs/business-logic.md | 264 ++++ app/modules/loyalty/docs/data-model.md | 235 +++ app/modules/loyalty/docs/index.md | 110 ++ app/modules/loyalty/docs/program-analysis.md | 387 +++++ app/modules/loyalty/docs/ui-design.md | 670 ++++++++ app/modules/loyalty/docs/user-journeys.md | 794 ++++++++++ app/modules/marketplace/docs/admin-guide.md | 261 ++++ app/modules/marketplace/docs/api.md | 322 ++++ app/modules/marketplace/docs/architecture.md | 1345 +++++++++++++++++ app/modules/marketplace/docs/data-model.md | 297 ++++ .../marketplace/docs/import-improvements.md | 601 ++++++++ app/modules/marketplace/docs/index.md | 73 + .../marketplace/docs/integration-guide.md | Bin 0 -> 19288 bytes app/modules/marketplace/docs/job-queue.md | 716 +++++++++ .../marketplace/docs/order-integration.md | 839 ++++++++++ app/modules/messaging/docs/architecture.md | 243 +++ app/modules/messaging/docs/data-model.md | 290 ++++ .../messaging/docs/email-settings-impl.md | 308 ++++ app/modules/messaging/docs/email-settings.md | 254 ++++ app/modules/messaging/docs/email-system.md | 331 ++++ app/modules/messaging/docs/index.md | 63 + app/modules/messaging/docs/notifications.md | 187 +++ app/modules/monitoring/docs/index.md | 44 + app/modules/orders/docs/architecture.md | 345 +++++ app/modules/orders/docs/data-model.md | 229 +++ app/modules/orders/docs/exceptions.md | 288 ++++ app/modules/orders/docs/index.md | 67 + app/modules/orders/docs/oms-features.md | 662 ++++++++ app/modules/orders/docs/stock-integration.md | 371 +++++ app/modules/orders/docs/unified-order-view.md | 275 ++++ app/modules/orders/docs/vat-invoicing.md | 734 +++++++++ app/modules/payments/docs/index.md | 46 + app/modules/prospecting/docs/index.md | 58 + app/modules/prospecting/docs/user-journeys.md | 435 ++++++ app/modules/tenancy/docs/data-model.md | 160 ++ app/modules/tenancy/docs/index.md | 64 + app/modules/tenancy/docs/migration.md | 243 +++ app/modules/tenancy/docs/onboarding.md | 191 +++ app/modules/tenancy/docs/rbac.md | 686 +++++++++ .../customer-orders-architecture.md | 346 +---- docs/architecture/marketplace-integration.md | 1344 +--------------- docs/architecture/module-system.md | 5 + docs/architecture/product-architecture.md | 292 +--- docs/architecture/tenancy-module-migration.md | 285 +--- docs/backend/store-rbac.md | 845 +---------- docs/deployment/stripe-integration.md | 618 +------- docs/development/creating-modules.md | 9 +- docs/development/module-documentation.md | 82 + docs/features/cms-implementation-guide.md | 415 +---- docs/features/content-management-system.md | 605 +------- docs/features/email-system.md | 332 +--- docs/features/store-onboarding.md | 190 +-- docs/features/subscription-billing.md | 630 +------- docs/features/user-journeys/hosting.md | 503 +----- docs/features/user-journeys/loyalty.md | 793 +--------- docs/features/user-journeys/prospecting.md | 436 +----- docs/getting-started/database-setup.md | 1 - docs/guides/csv-import.md | 0 docs/guides/email-settings.md | 255 +--- docs/guides/email-templates.md | 288 +--- docs/guides/inventory-management.md | 367 +---- docs/guides/letzshop-admin-management.md | 260 +--- docs/guides/letzshop-marketplace-api.md | 321 +--- docs/guides/letzshop-order-integration.md | 838 +--------- docs/guides/marketplace-integration.md | Bin 19285 -> 154 bytes docs/guides/media-library.md | 183 +-- docs/guides/product-management.md | 0 docs/guides/shop-setup.md | 0 docs/guides/subscription-tier-management.md | 134 +- .../admin-notification-system.md | 188 +-- docs/implementation/email-settings.md | 309 +--- .../email-templates-architecture.md | 459 +----- docs/implementation/feature-gating-system.md | 433 +----- .../letzshop-jobs-improvements.md | 715 +-------- .../letzshop-order-import-improvements.md | 600 +------- docs/implementation/messaging-system.md | 244 +-- docs/implementation/oms-feature-plan.md | 663 +------- docs/implementation/order-item-exceptions.md | 289 +--- .../stock-management-integration.md | 372 +---- .../subscription-workflow-plan.md | 453 +----- docs/implementation/unified-order-view.md | 276 +--- docs/implementation/vat-invoice-feature.md | 735 +-------- docs/index.md | 3 - docs/modules/analytics | 1 + docs/modules/billing | 1 + docs/modules/cart | 1 + docs/modules/catalog | 1 + docs/modules/checkout | 1 + docs/modules/cms | 1 + docs/modules/contracts | 1 + docs/modules/core | 1 + docs/modules/customers | 1 + docs/modules/dev_tools | 1 + docs/modules/hosting | 1 + docs/modules/inventory | 1 + docs/modules/loyalty | 1 + docs/modules/loyalty.md | 321 ---- docs/modules/marketplace | 1 + docs/modules/messaging | 1 + docs/modules/monitoring | 1 + docs/modules/orders | 1 + docs/modules/payments | 1 + docs/modules/prospecting | 1 + docs/modules/prospecting/database.md | 171 --- docs/modules/prospecting/research-findings.md | 80 - docs/modules/prospecting/scoring.md | 110 -- docs/modules/tenancy | 1 + .../loyalty-phase2-interfaces-plan.md | 669 +------- docs/proposals/loyalty-program-analysis.md | 386 +---- .../module-documentation-migration-plan.md | 242 +++ mkdocs.yml | 91 +- 140 files changed, 19921 insertions(+), 17723 deletions(-) create mode 100644 app/modules/analytics/docs/index.md create mode 100644 app/modules/billing/docs/data-model.md create mode 100644 app/modules/billing/docs/feature-gating.md create mode 100644 app/modules/billing/docs/index.md create mode 100644 app/modules/billing/docs/stripe-integration.md create mode 100644 app/modules/billing/docs/subscription-system.md create mode 100644 app/modules/billing/docs/subscription-workflow.md create mode 100644 app/modules/billing/docs/tier-management.md create mode 100644 app/modules/cart/docs/index.md create mode 100644 app/modules/catalog/docs/architecture.md create mode 100644 app/modules/catalog/docs/data-model.md create mode 100644 app/modules/catalog/docs/index.md create mode 100644 app/modules/checkout/docs/index.md create mode 100644 app/modules/cms/docs/architecture.md create mode 100644 app/modules/cms/docs/data-model.md create mode 100644 app/modules/cms/docs/email-templates-guide.md create mode 100644 app/modules/cms/docs/email-templates.md create mode 100644 app/modules/cms/docs/implementation.md create mode 100644 app/modules/cms/docs/index.md create mode 100644 app/modules/cms/docs/media-library.md create mode 100644 app/modules/contracts/docs/index.md create mode 100644 app/modules/core/docs/index.md create mode 100644 app/modules/customers/docs/index.md create mode 100644 app/modules/dev_tools/docs/index.md create mode 100644 app/modules/hosting/docs/index.md create mode 100644 app/modules/hosting/docs/user-journeys.md create mode 100644 app/modules/inventory/docs/data-model.md create mode 100644 app/modules/inventory/docs/index.md create mode 100644 app/modules/inventory/docs/user-guide.md create mode 100644 app/modules/loyalty/docs/business-logic.md create mode 100644 app/modules/loyalty/docs/data-model.md create mode 100644 app/modules/loyalty/docs/index.md create mode 100644 app/modules/loyalty/docs/program-analysis.md create mode 100644 app/modules/loyalty/docs/ui-design.md create mode 100644 app/modules/loyalty/docs/user-journeys.md create mode 100644 app/modules/marketplace/docs/admin-guide.md create mode 100644 app/modules/marketplace/docs/api.md create mode 100644 app/modules/marketplace/docs/architecture.md create mode 100644 app/modules/marketplace/docs/data-model.md create mode 100644 app/modules/marketplace/docs/import-improvements.md create mode 100644 app/modules/marketplace/docs/index.md create mode 100644 app/modules/marketplace/docs/integration-guide.md create mode 100644 app/modules/marketplace/docs/job-queue.md create mode 100644 app/modules/marketplace/docs/order-integration.md create mode 100644 app/modules/messaging/docs/architecture.md create mode 100644 app/modules/messaging/docs/data-model.md create mode 100644 app/modules/messaging/docs/email-settings-impl.md create mode 100644 app/modules/messaging/docs/email-settings.md create mode 100644 app/modules/messaging/docs/email-system.md create mode 100644 app/modules/messaging/docs/index.md create mode 100644 app/modules/messaging/docs/notifications.md create mode 100644 app/modules/monitoring/docs/index.md create mode 100644 app/modules/orders/docs/architecture.md create mode 100644 app/modules/orders/docs/data-model.md create mode 100644 app/modules/orders/docs/exceptions.md create mode 100644 app/modules/orders/docs/index.md create mode 100644 app/modules/orders/docs/oms-features.md create mode 100644 app/modules/orders/docs/stock-integration.md create mode 100644 app/modules/orders/docs/unified-order-view.md create mode 100644 app/modules/orders/docs/vat-invoicing.md create mode 100644 app/modules/payments/docs/index.md create mode 100644 app/modules/prospecting/docs/index.md create mode 100644 app/modules/prospecting/docs/user-journeys.md create mode 100644 app/modules/tenancy/docs/data-model.md create mode 100644 app/modules/tenancy/docs/index.md create mode 100644 app/modules/tenancy/docs/migration.md create mode 100644 app/modules/tenancy/docs/onboarding.md create mode 100644 app/modules/tenancy/docs/rbac.md create mode 100644 docs/development/module-documentation.md delete mode 100644 docs/guides/csv-import.md delete mode 100644 docs/guides/product-management.md delete mode 100644 docs/guides/shop-setup.md create mode 120000 docs/modules/analytics create mode 120000 docs/modules/billing create mode 120000 docs/modules/cart create mode 120000 docs/modules/catalog create mode 120000 docs/modules/checkout create mode 120000 docs/modules/cms create mode 120000 docs/modules/contracts create mode 120000 docs/modules/core create mode 120000 docs/modules/customers create mode 120000 docs/modules/dev_tools create mode 120000 docs/modules/hosting create mode 120000 docs/modules/inventory create mode 120000 docs/modules/loyalty delete mode 100644 docs/modules/loyalty.md create mode 120000 docs/modules/marketplace create mode 120000 docs/modules/messaging create mode 120000 docs/modules/monitoring create mode 120000 docs/modules/orders create mode 120000 docs/modules/payments create mode 120000 docs/modules/prospecting delete mode 100644 docs/modules/prospecting/database.md delete mode 100644 docs/modules/prospecting/research-findings.md delete mode 100644 docs/modules/prospecting/scoring.md create mode 120000 docs/modules/tenancy create mode 100644 docs/proposals/module-documentation-migration-plan.md diff --git a/app/modules/analytics/docs/index.md b/app/modules/analytics/docs/index.md new file mode 100644 index 00000000..eea9c2cb --- /dev/null +++ b/app/modules/analytics/docs/index.md @@ -0,0 +1,42 @@ +# Analytics & Reporting + +Dashboard analytics, custom reports, and data exports. + +## Overview + +| Aspect | Detail | +|--------|--------| +| Code | `analytics` | +| Classification | Optional | +| Dependencies | `catalog`, `inventory`, `marketplace`, `orders` | +| Status | Active | + +## Features + +- `basic_reports` — Standard built-in reports +- `analytics_dashboard` — Analytics dashboard widgets +- `custom_reports` — Custom report builder +- `export_reports` — Report data export +- `usage_metrics` — Platform usage metrics + +## Permissions + +| Permission | Description | +|------------|-------------| +| `analytics.view` | View analytics and reports | +| `analytics.export` | Export report data | +| `analytics.manage_dashboards` | Create/edit custom dashboards | + +## Data Model + +Analytics primarily queries data from other modules (orders, inventory, catalog). + +## API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/v1/store/analytics/*` | Store analytics data | + +## Configuration + +No module-specific configuration. diff --git a/app/modules/billing/docs/data-model.md b/app/modules/billing/docs/data-model.md new file mode 100644 index 00000000..444154cc --- /dev/null +++ b/app/modules/billing/docs/data-model.md @@ -0,0 +1,138 @@ +# Billing Data Model + +## Entity Relationship Overview + +``` +┌───────────────────┐ +│ SubscriptionTier │ +└────────┬──────────┘ + │ 1:N + ▼ +┌───────────────────┐ ┌──────────────────────┐ +│ TierFeatureLimit │ │ MerchantSubscription │ +│ (feature limits) │ │ (per merchant+plat) │ +└───────────────────┘ └──────────┬───────────┘ + │ + ┌──────────┼──────────────┐ + ▼ ▼ ▼ + ┌────────────┐ ┌──────────┐ ┌─────────────┐ + │ BillingHist│ │StoreAddOn│ │FeatureOverride│ + └────────────┘ └──────────┘ └─────────────┘ + │ + ▼ + ┌────────────┐ + │AddOnProduct│ + └────────────┘ + +┌──────────────────────┐ +│StripeWebhookEvent │ (idempotency tracking) +└──────────────────────┘ +``` + +## Core Entities + +### SubscriptionTier + +Defines available subscription plans with pricing and Stripe integration. + +| Column | Type | Description | +|--------|------|-------------| +| `id` | Integer | Primary key | +| `code` | String | Unique tier code (`essential`, `professional`, `business`, `enterprise`) | +| `name` | String | Display name | +| `price_monthly_cents` | Integer | Monthly price in cents | +| `price_annual_cents` | Integer | Annual price in cents (optional) | +| `stripe_product_id` | String | Stripe product ID | +| `stripe_price_monthly_id` | String | Stripe monthly price ID | +| `stripe_price_annual_id` | String | Stripe annual price ID | +| `display_order` | Integer | Sort order on pricing pages | +| `is_active` | Boolean | Available for subscription | +| `is_public` | Boolean | Visible to stores | + +### TierFeatureLimit + +Per-tier feature limits — each row links a tier to a feature code with a limit value. + +| Column | Type | Description | +|--------|------|-------------| +| `id` | Integer | Primary key | +| `tier_id` | Integer | FK to SubscriptionTier | +| `feature_code` | String | Feature identifier (e.g., `max_products`) | +| `limit_value` | Integer | Numeric limit (NULL = unlimited) | +| `enabled` | Boolean | Whether feature is enabled for this tier | + +### MerchantSubscription + +Per-merchant+platform subscription state. Subscriptions are merchant-level, not store-level. + +| Column | Type | Description | +|--------|------|-------------| +| `id` | Integer | Primary key | +| `merchant_id` | Integer | FK to Merchant | +| `platform_id` | Integer | FK to Platform | +| `tier_id` | Integer | FK to SubscriptionTier | +| `tier_code` | String | Tier code (denormalized for convenience) | +| `status` | SubscriptionStatus | `trial`, `active`, `past_due`, `cancelled`, `expired` | +| `stripe_customer_id` | String | Stripe customer ID | +| `stripe_subscription_id` | String | Stripe subscription ID | +| `trial_ends_at` | DateTime | Trial expiry | +| `period_start` | DateTime | Current billing period start | +| `period_end` | DateTime | Current billing period end | + +### MerchantFeatureOverride + +Per-merchant exceptions to tier defaults (e.g., enterprise custom limits). + +| Column | Type | Description | +|--------|------|-------------| +| `id` | Integer | Primary key | +| `merchant_id` | Integer | FK to Merchant | +| `feature_code` | String | Feature identifier | +| `limit_value` | Integer | Override limit (NULL = unlimited) | + +## Add-on Entities + +### AddOnProduct + +Purchasable add-on items (domains, SSL, email packages). + +| Column | Type | Description | +|--------|------|-------------| +| `id` | Integer | Primary key | +| `code` | String | Unique add-on code | +| `name` | String | Display name | +| `category` | AddOnCategory | `domain`, `ssl`, `email` | +| `price_cents` | Integer | Price in cents | +| `billing_period` | BillingPeriod | `monthly` or `yearly` | + +### StoreAddOn + +Add-ons purchased by individual stores. + +| Column | Type | Description | +|--------|------|-------------| +| `id` | Integer | Primary key | +| `store_id` | Integer | FK to Store | +| `addon_product_id` | Integer | FK to AddOnProduct | +| `config` | JSON | Configuration (e.g., domain name) | +| `stripe_subscription_item_id` | String | Stripe subscription item ID | +| `status` | String | `active`, `cancelled`, `pending_setup` | + +## Supporting Entities + +### BillingHistory + +Invoice and payment history records. + +### StripeWebhookEvent + +Idempotency tracking for Stripe webhook events. Prevents duplicate event processing. + +## Key Relationships + +- A **SubscriptionTier** has many **TierFeatureLimits** (one per feature) +- A **Merchant** has one **MerchantSubscription** per Platform +- A **MerchantSubscription** references one **SubscriptionTier** +- A **Merchant** can have many **MerchantFeatureOverrides** (per-feature) +- A **Store** can purchase many **StoreAddOns** +- Feature limits are resolved: MerchantFeatureOverride > TierFeatureLimit > default diff --git a/app/modules/billing/docs/feature-gating.md b/app/modules/billing/docs/feature-gating.md new file mode 100644 index 00000000..ff8414ae --- /dev/null +++ b/app/modules/billing/docs/feature-gating.md @@ -0,0 +1,434 @@ +# Feature Gating System + +## Overview + +The feature gating system provides tier-based access control for platform features. It allows restricting functionality based on store subscription tiers (Essential, Professional, Business, Enterprise) with contextual upgrade prompts when features are locked. + +**Implemented:** December 31, 2025 + +## Architecture + +### Database Models + +Located in `models/database/feature.py`: + +| Model | Purpose | +|-------|---------| +| `Feature` | Feature definitions with tier requirements | +| `StoreFeatureOverride` | Per-store feature overrides (enable/disable) | + +### Feature Model Structure + +```python +class Feature(Base): + __tablename__ = "features" + + id: int # Primary key + code: str # Unique feature code (e.g., "analytics_dashboard") + name: str # Display name + description: str # User-facing description + category: str # Feature category + minimum_tier_code: str # Minimum tier required (essential/professional/business/enterprise) + minimum_tier_order: int # Tier order for comparison (1-4) + is_active: bool # Whether feature is available + created_at: datetime + updated_at: datetime +``` + +### Tier Ordering + +| Tier | Order | Code | +|------|-------|------| +| Essential | 1 | `essential` | +| Professional | 2 | `professional` | +| Business | 3 | `business` | +| Enterprise | 4 | `enterprise` | + +## Feature Categories + +30 features organized into 8 categories: + +### 1. Analytics +| Feature Code | Name | Min Tier | +|-------------|------|----------| +| `basic_analytics` | Basic Analytics | Essential | +| `analytics_dashboard` | Analytics Dashboard | Professional | +| `advanced_analytics` | Advanced Analytics | Business | +| `custom_reports` | Custom Reports | Enterprise | + +### 2. Product Management +| Feature Code | Name | Min Tier | +|-------------|------|----------| +| `basic_products` | Product Management | Essential | +| `bulk_product_edit` | Bulk Product Edit | Professional | +| `product_variants` | Product Variants | Professional | +| `product_bundles` | Product Bundles | Business | +| `inventory_alerts` | Inventory Alerts | Professional | + +### 3. Order Management +| Feature Code | Name | Min Tier | +|-------------|------|----------| +| `basic_orders` | Order Management | Essential | +| `order_automation` | Order Automation | Professional | +| `advanced_fulfillment` | Advanced Fulfillment | Business | +| `multi_warehouse` | Multi-Warehouse | Enterprise | + +### 4. Marketing +| Feature Code | Name | Min Tier | +|-------------|------|----------| +| `discount_codes` | Discount Codes | Professional | +| `abandoned_cart` | Abandoned Cart Recovery | Business | +| `email_marketing` | Email Marketing | Business | +| `loyalty_program` | Loyalty Program | Enterprise | + +### 5. Support +| Feature Code | Name | Min Tier | +|-------------|------|----------| +| `basic_support` | Email Support | Essential | +| `priority_support` | Priority Support | Professional | +| `phone_support` | Phone Support | Business | +| `dedicated_manager` | Dedicated Account Manager | Enterprise | + +### 6. Integration +| Feature Code | Name | Min Tier | +|-------------|------|----------| +| `basic_api` | Basic API Access | Professional | +| `advanced_api` | Advanced API Access | Business | +| `webhooks` | Webhooks | Business | +| `custom_integrations` | Custom Integrations | Enterprise | + +### 7. Branding +| Feature Code | Name | Min Tier | +|-------------|------|----------| +| `basic_theme` | Theme Customization | Essential | +| `custom_domain` | Custom Domain | Professional | +| `white_label` | White Label | Enterprise | +| `custom_checkout` | Custom Checkout | Enterprise | + +### 8. Team +| Feature Code | Name | Min Tier | +|-------------|------|----------| +| `team_management` | Team Management | Professional | +| `role_permissions` | Role Permissions | Business | +| `audit_logs` | Audit Logs | Business | + +## Services + +### FeatureService + +Located in `app/services/feature_service.py`: + +```python +class FeatureService: + """Service for managing tier-based feature access.""" + + # In-memory caching (refreshed every 5 minutes) + _feature_cache: dict[str, Feature] = {} + _cache_timestamp: datetime | None = None + CACHE_TTL_SECONDS = 300 + + def has_feature(self, db: Session, store_id: int, feature_code: str) -> bool: + """Check if store has access to a feature.""" + + def get_available_features(self, db: Session, store_id: int) -> list[str]: + """Get list of feature codes available to store.""" + + def get_all_features_with_status(self, db: Session, store_id: int) -> list[dict]: + """Get all features with availability status for store.""" + + def get_feature_info(self, db: Session, feature_code: str) -> dict | None: + """Get full feature information including tier requirements.""" +``` + +### UsageService + +Located in `app/services/usage_service.py`: + +```python +class UsageService: + """Service for tracking and managing store usage against tier limits.""" + + def get_usage_summary(self, db: Session, store_id: int) -> dict: + """Get comprehensive usage summary with limits and upgrade info.""" + + def check_limit(self, db: Session, store_id: int, limit_type: str) -> dict: + """Check specific limit with detailed info.""" + + def get_upgrade_info(self, db: Session, store_id: int) -> dict: + """Get upgrade recommendations based on current usage.""" +``` + +## Backend Enforcement + +### Decorator Pattern + +```python +from app.core.feature_gate import require_feature + +@router.get("/analytics/advanced") +@require_feature("advanced_analytics") +async def get_advanced_analytics( + db: Session = Depends(get_db), + store_id: int = Depends(get_current_store_id) +): + # Only accessible if store has advanced_analytics feature + pass +``` + +### Dependency Pattern + +```python +from app.core.feature_gate import RequireFeature + +@router.get("/marketing/loyalty") +async def get_loyalty_program( + db: Session = Depends(get_db), + _: None = Depends(RequireFeature("loyalty_program")) +): + # Only accessible if store has loyalty_program feature + pass +``` + +### Exception Handling + +When a feature is not available, `FeatureNotAvailableException` is raised: + +```python +class FeatureNotAvailableException(Exception): + def __init__(self, feature_code: str, required_tier: str): + self.feature_code = feature_code + self.required_tier = required_tier + super().__init__(f"Feature '{feature_code}' requires {required_tier} tier") +``` + +HTTP Response (403): +```json +{ + "detail": "Feature 'advanced_analytics' requires Professional tier or higher", + "feature_code": "advanced_analytics", + "required_tier": "Professional", + "upgrade_url": "/store/orion/billing" +} +``` + +## API Endpoints + +### Store Features API + +Base: `/api/v1/store/features` + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/features/available` | GET | List available feature codes | +| `/features` | GET | All features with availability status | +| `/features/{code}` | GET | Single feature info | +| `/features/{code}/check` | GET | Quick availability check | + +### Store Usage API + +Base: `/api/v1/store/usage` + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/usage` | GET | Full usage summary with limits | +| `/usage/check/{limit_type}` | GET | Check specific limit (orders/products/team_members) | +| `/usage/upgrade-info` | GET | Upgrade recommendations | + +### Admin Features API + +Base: `/api/v1/admin/features` + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/features` | GET | List all features | +| `/features/{id}` | GET | Get feature details | +| `/features/{id}` | PUT | Update feature | +| `/features/{id}/toggle` | POST | Toggle feature active status | +| `/features/stores/{store_id}/overrides` | GET | Get store overrides | +| `/features/stores/{store_id}/overrides` | POST | Create override | + +## Frontend Integration + +### Alpine.js Feature Store + +Located in `static/shared/js/feature-store.js`: + +```javascript +// Usage in templates +$store.features.has('analytics_dashboard') // Check feature +$store.features.loaded // Loading state +$store.features.getFeature('advanced_api') // Get feature details +``` + +### Alpine.js Upgrade Store + +Located in `static/shared/js/upgrade-prompts.js`: + +```javascript +// Usage in templates +$store.upgrade.shouldShowLimitWarning('orders') +$store.upgrade.getUsageString('products') +$store.upgrade.hasUpgradeRecommendation +``` + +### Jinja2 Macros + +Located in `app/templates/shared/macros/feature_gate.html`: + +#### Feature Gate Container +```jinja2 +{% from "shared/macros/feature_gate.html" import feature_gate %} + +{% call feature_gate("analytics_dashboard") %} +
Analytics content here - only visible if feature available
+{% endcall %} +``` + +#### Feature Locked Card +```jinja2 +{% from "shared/macros/feature_gate.html" import feature_locked %} + +{{ feature_locked("advanced_analytics", "Advanced Analytics", "Get deeper insights") }} +``` + +#### Upgrade Banner +```jinja2 +{% from "shared/macros/feature_gate.html" import upgrade_banner %} + +{{ upgrade_banner("custom_domain") }} +``` + +#### Usage Limit Warning +```jinja2 +{% from "shared/macros/feature_gate.html" import limit_warning %} + +{{ limit_warning("orders") }} {# Shows warning when approaching limit #} +``` + +#### Usage Progress Bar +```jinja2 +{% from "shared/macros/feature_gate.html" import usage_bar %} + +{{ usage_bar("products", "Products") }} +``` + +#### Tier Badge +```jinja2 +{% from "shared/macros/feature_gate.html" import tier_badge %} + +{{ tier_badge() }} {# Shows current tier as colored badge #} +``` + +## Store Dashboard Integration + +The store dashboard (`/store/{code}/dashboard`) now includes: + +1. **Tier Badge**: Shows current subscription tier in header +2. **Usage Bars**: Visual progress bars for orders, products, team members +3. **Upgrade Prompts**: Contextual upgrade recommendations when approaching limits +4. **Feature Gates**: Locked sections for premium features + +## Admin Features Page + +Located at `/admin/features`: + +- View all 30 features in categorized table +- Toggle features on/off globally +- Filter by category +- Search by name/code +- View tier requirements + +## Admin Tier Management UI + +Located at `/admin/subscription-tiers`: + +### Overview + +The subscription tiers admin page provides full CRUD functionality for managing subscription tiers and their feature assignments. + +### Features + +1. **Stats Cards**: Display total tiers, active tiers, public tiers, and estimated MRR +2. **Tier Table**: Sortable list of all tiers with: + - Display order + - Code (colored badge by tier) + - Name + - Monthly/Annual pricing + - Feature count + - Status (Active/Private/Inactive) + - Actions (Edit Features, Edit, Activate/Deactivate) + +3. **Create/Edit Modal**: Form with all tier fields: + - Code and Name + - Monthly and Annual pricing (in cents) + - Display order + - Stripe IDs (optional) + - Description + - Active/Public toggles + +4. **Feature Assignment Slide-over Panel**: + - Opens when clicking the puzzle-piece icon + - Shows all features grouped by category + - Binary features: checkbox selection with Select all/Deselect all per category + - Quantitative features: checkbox + numeric limit input for `limit_value` + - Feature count in footer + - Save to update tier's feature assignments via `TierFeatureLimitEntry[]` + +### Files + +| File | Purpose | +|------|---------| +| `app/templates/admin/subscription-tiers.html` | Page template | +| `static/admin/js/subscription-tiers.js` | Alpine.js component | +| `app/routes/admin_pages.py` | Route registration | + +### API Endpoints Used + +| Action | Method | Endpoint | +|--------|--------|----------| +| Load tiers | GET | `/api/v1/admin/subscriptions/tiers` | +| Load stats | GET | `/api/v1/admin/subscriptions/stats` | +| Create tier | POST | `/api/v1/admin/subscriptions/tiers` | +| Update tier | PATCH | `/api/v1/admin/subscriptions/tiers/{code}` | +| Delete tier | DELETE | `/api/v1/admin/subscriptions/tiers/{code}` | +| Load feature catalog | GET | `/api/v1/admin/subscriptions/features/catalog` | +| Get tier feature limits | GET | `/api/v1/admin/subscriptions/features/tiers/{code}/limits` | +| Update tier feature limits | PUT | `/api/v1/admin/subscriptions/features/tiers/{code}/limits` | + +## Migration + +The features are seeded via Alembic migration: + +``` +alembic/versions/n2c3d4e5f6a7_add_features_table.py +``` + +This creates: +- `features` table with 30 default features +- `store_feature_overrides` table for per-store exceptions + +## Testing + +Unit tests located in: +- `tests/unit/services/test_feature_service.py` +- `tests/unit/services/test_usage_service.py` + +Run tests: +```bash +pytest tests/unit/services/test_feature_service.py -v +pytest tests/unit/services/test_usage_service.py -v +``` + +## Architecture Compliance + +All JavaScript files follow architecture rules: +- JS-003: Alpine components use `store*` naming convention +- JS-005: Init guards prevent duplicate initialization +- JS-006: Async operations have try/catch error handling +- JS-008: API calls use `apiClient` (not raw `fetch()`) +- JS-009: Notifications use `Utils.showToast()` + +## Related Documentation + +- [Subscription Billing](subscription-system.md) - Core subscription system +- [Subscription Workflow Plan](subscription-workflow.md) - Implementation roadmap diff --git a/app/modules/billing/docs/index.md b/app/modules/billing/docs/index.md new file mode 100644 index 00000000..73e6e178 --- /dev/null +++ b/app/modules/billing/docs/index.md @@ -0,0 +1,74 @@ +# Billing & Subscriptions + +Core subscription management, tier limits, store billing, and invoice history. Provides tier-based feature gating used throughout the platform. Uses the payments module for actual payment processing. + +## Overview + +| Aspect | Detail | +|--------|--------| +| Code | `billing` | +| Classification | Core | +| Dependencies | `payments` | +| Status | Active | + +## Features + +- `subscription_management` — Subscription lifecycle management +- `billing_history` — Billing and payment history +- `invoice_generation` — Automatic invoice generation +- `subscription_analytics` — Subscription metrics and analytics +- `trial_management` — Free trial period management +- `limit_overrides` — Per-store tier limit overrides + +## Permissions + +| Permission | Description | +|------------|-------------| +| `billing.view_tiers` | View subscription tiers | +| `billing.manage_tiers` | Manage subscription tiers | +| `billing.view_subscriptions` | View subscriptions | +| `billing.manage_subscriptions` | Manage subscriptions | +| `billing.view_invoices` | View invoices | + +## Data Model + +See [Data Model](data-model.md) for full entity relationships. + +- **SubscriptionTier** — Tier definitions with Stripe price IDs +- **TierFeatureLimit** — Per-tier feature limits (feature_code + limit_value) +- **MerchantSubscription** — Per-merchant+platform subscription state +- **MerchantFeatureOverride** — Per-merchant feature limit overrides +- **AddOnProduct / StoreAddOn** — Purchasable add-ons +- **BillingHistory** — Invoice and payment records +- **StripeWebhookEvent** — Webhook idempotency tracking + +## API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `*` | `/api/v1/admin/billing/*` | Admin billing management | +| `*` | `/api/v1/admin/features/*` | Feature/tier management | +| `*` | `/api/v1/merchant/billing/*` | Merchant billing endpoints | +| `*` | `/api/v1/platform/billing/*` | Platform-wide billing stats | + +## Scheduled Tasks + +| Task | Schedule | Description | +|------|----------|-------------| +| `billing.reset_period_counters` | Daily 00:05 | Reset period-based usage counters | +| `billing.check_trial_expirations` | Daily 01:00 | Check and handle expired trials | +| `billing.sync_stripe_status` | Hourly :30 | Sync subscription status with Stripe | +| `billing.cleanup_stale_subscriptions` | Weekly Sunday 03:00 | Clean up stale subscription records | + +## Configuration + +Configured via Stripe environment variables and tier definitions in the admin panel. + +## Additional Documentation + +- [Data Model](data-model.md) — Entity relationships and database schema +- [Subscription System](subscription-system.md) — Architecture, feature providers, API reference +- [Feature Gating](feature-gating.md) — Tier-based feature access control and UI integration +- [Tier Management](tier-management.md) — Admin guide for managing subscription tiers +- [Subscription Workflow](subscription-workflow.md) — Subscription lifecycle and implementation phases +- [Stripe Integration](stripe-integration.md) — Stripe Connect setup, webhooks, payment flow diff --git a/app/modules/billing/docs/stripe-integration.md b/app/modules/billing/docs/stripe-integration.md new file mode 100644 index 00000000..d06817ee --- /dev/null +++ b/app/modules/billing/docs/stripe-integration.md @@ -0,0 +1,617 @@ +# Stripe Payment Integration - Multi-Tenant Ecommerce Platform + +## Architecture Overview + +The payment integration uses **Stripe Connect** to handle multi-store payments, enabling: +- Each store to receive payments directly +- Platform to collect fees/commissions +- Proper financial isolation between stores +- Compliance with financial regulations + +## Payment Models + +### Database Models + +```python +# models/database/payment.py +from decimal import Decimal +from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, Numeric +from sqlalchemy.orm import relationship +from app.core.database import Base +from .base import TimestampMixin + + +class StorePaymentConfig(Base, TimestampMixin): + """Store-specific payment configuration.""" + __tablename__ = "store_payment_configs" + + id = Column(Integer, primary_key=True, index=True) + store_id = Column(Integer, ForeignKey("stores.id"), nullable=False, unique=True) + + # Stripe Connect configuration + stripe_account_id = Column(String(255)) # Stripe Connect account ID + stripe_account_status = Column(String(50)) # pending, active, restricted, inactive + stripe_onboarding_url = Column(Text) # Onboarding link for store + stripe_dashboard_url = Column(Text) # Store's Stripe dashboard + + # Payment settings + accepts_payments = Column(Boolean, default=False) + currency = Column(String(3), default="EUR") + platform_fee_percentage = Column(Numeric(5, 2), default=2.5) # Platform commission + + # Payout settings + payout_schedule = Column(String(20), default="weekly") # daily, weekly, monthly + minimum_payout = Column(Numeric(10, 2), default=20.00) + + # Relationships + store = relationship("Store", back_populates="payment_config") + + def __repr__(self): + return f"" + + +class Payment(Base, TimestampMixin): + """Payment records for orders.""" + __tablename__ = "payments" + + id = Column(Integer, primary_key=True, index=True) + store_id = Column(Integer, ForeignKey("stores.id"), nullable=False) + order_id = Column(Integer, ForeignKey("orders.id"), nullable=False) + customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False) + + # Stripe payment details + stripe_payment_intent_id = Column(String(255), unique=True, index=True) + stripe_charge_id = Column(String(255), index=True) + stripe_transfer_id = Column(String(255)) # Transfer to store account + + # Payment amounts (in cents to avoid floating point issues) + amount_total = Column(Integer, nullable=False) # Total customer payment + amount_store = Column(Integer, nullable=False) # Amount to store + amount_platform_fee = Column(Integer, nullable=False) # Platform commission + currency = Column(String(3), default="EUR") + + # Payment status + status = Column(String(50), nullable=False) # pending, succeeded, failed, refunded + payment_method = Column(String(50)) # card, bank_transfer, etc. + + # Metadata + stripe_metadata = Column(Text) # JSON string of Stripe metadata + failure_reason = Column(Text) + refund_reason = Column(Text) + + # Timestamps + paid_at = Column(DateTime) + refunded_at = Column(DateTime) + + # Relationships + store = relationship("Store") + order = relationship("Order", back_populates="payment") + customer = relationship("Customer") + + def __repr__(self): + return f"" + + @property + def amount_total_euros(self): + """Convert cents to euros for display.""" + return self.amount_total / 100 + + @property + def amount_store_euros(self): + """Convert cents to euros for display.""" + return self.amount_store / 100 + + +class PaymentMethod(Base, TimestampMixin): + """Saved customer payment methods.""" + __tablename__ = "payment_methods" + + id = Column(Integer, primary_key=True, index=True) + store_id = Column(Integer, ForeignKey("stores.id"), nullable=False) + customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False) + + # Stripe payment method details + stripe_payment_method_id = Column(String(255), nullable=False, index=True) + payment_method_type = Column(String(50), nullable=False) # card, sepa_debit, etc. + + # Card details (if applicable) + card_brand = Column(String(50)) # visa, mastercard, etc. + card_last4 = Column(String(4)) + card_exp_month = Column(Integer) + card_exp_year = Column(Integer) + + # Settings + is_default = Column(Boolean, default=False) + is_active = Column(Boolean, default=True) + + # Relationships + store = relationship("Store") + customer = relationship("Customer") + + def __repr__(self): + return f"" +``` + +### Updated Order Model + +```python +# Update models/database/order.py +class Order(Base, TimestampMixin): + # ... existing fields ... + + # Payment integration + payment_status = Column(String(50), default="pending") # pending, paid, failed, refunded + payment_intent_id = Column(String(255)) # Stripe PaymentIntent ID + total_amount_cents = Column(Integer, nullable=False) # Amount in cents + + # Relationships + payment = relationship("Payment", back_populates="order", uselist=False) + + @property + def total_amount_euros(self): + """Convert cents to euros for display.""" + return self.total_amount_cents / 100 if self.total_amount_cents else 0 +``` + +## Payment Service Integration + +### Stripe Service + +```python +# services/payment_service.py +import stripe +import json +import logging +from decimal import Decimal +from typing import Dict, Optional +from sqlalchemy.orm import Session + +from app.core.config import settings +from models.database.payment import Payment, StorePaymentConfig +from models.database.order import Order +from models.database.store import Store +from app.exceptions.payment import * + +logger = logging.getLogger(__name__) + +# Configure Stripe +stripe.api_key = settings.stripe_secret_key + + +class PaymentService: + """Service for handling Stripe payments in multi-tenant environment.""" + + def __init__(self, db: Session): + self.db = db + + def create_payment_intent( + self, + store_id: int, + order_id: int, + amount_euros: Decimal, + customer_email: str, + metadata: Optional[Dict] = None + ) -> Dict: + """Create Stripe PaymentIntent for store order.""" + + # Get store payment configuration + payment_config = self.get_store_payment_config(store_id) + if not payment_config.accepts_payments: + raise PaymentNotConfiguredException(f"Store {store_id} not configured for payments") + + # Calculate amounts + amount_cents = int(amount_euros * 100) + platform_fee_cents = int(amount_cents * (payment_config.platform_fee_percentage / 100)) + store_amount_cents = amount_cents - platform_fee_cents + + try: + # Create PaymentIntent with Stripe Connect + payment_intent = stripe.PaymentIntent.create( + amount=amount_cents, + currency=payment_config.currency.lower(), + application_fee_amount=platform_fee_cents, + transfer_data={ + 'destination': payment_config.stripe_account_id, + }, + metadata={ + 'store_id': str(store_id), + 'order_id': str(order_id), + 'platform': 'multi_tenant_ecommerce', + **(metadata or {}) + }, + receipt_email=customer_email, + description=f"Order payment for store {store_id}" + ) + + # Create payment record + payment = Payment( + store_id=store_id, + order_id=order_id, + customer_id=self.get_order_customer_id(order_id), + stripe_payment_intent_id=payment_intent.id, + amount_total=amount_cents, + amount_store=store_amount_cents, + amount_platform_fee=platform_fee_cents, + currency=payment_config.currency, + status='pending', + stripe_metadata=json.dumps(payment_intent.metadata) + ) + + self.db.add(payment) + + # Update order + order = self.db.query(Order).filter(Order.id == order_id).first() + if order: + order.payment_intent_id = payment_intent.id + order.payment_status = 'pending' + + self.db.commit() + + return { + 'payment_intent_id': payment_intent.id, + 'client_secret': payment_intent.client_secret, + 'amount_total': amount_euros, + 'amount_store': store_amount_cents / 100, + 'platform_fee': platform_fee_cents / 100, + 'currency': payment_config.currency + } + + except stripe.error.StripeError as e: + logger.error(f"Stripe error creating PaymentIntent: {e}") + raise PaymentProcessingException(f"Payment processing failed: {str(e)}") + + def confirm_payment(self, payment_intent_id: str) -> Payment: + """Confirm payment and update records.""" + + try: + # Retrieve PaymentIntent from Stripe + payment_intent = stripe.PaymentIntent.retrieve(payment_intent_id) + + # Find payment record + payment = self.db.query(Payment).filter( + Payment.stripe_payment_intent_id == payment_intent_id + ).first() + + if not payment: + raise PaymentNotFoundException(f"Payment not found for intent {payment_intent_id}") + + # Update payment status based on Stripe status + if payment_intent.status == 'succeeded': + payment.status = 'succeeded' + payment.stripe_charge_id = payment_intent.charges.data[0].id if payment_intent.charges.data else None + payment.paid_at = datetime.utcnow() + + # Update order status + order = self.db.query(Order).filter(Order.id == payment.order_id).first() + if order: + order.payment_status = 'paid' + order.status = 'processing' # Move order to processing + + elif payment_intent.status == 'payment_failed': + payment.status = 'failed' + payment.failure_reason = payment_intent.last_payment_error.message if payment_intent.last_payment_error else "Unknown error" + + # Update order status + order = self.db.query(Order).filter(Order.id == payment.order_id).first() + if order: + order.payment_status = 'failed' + + self.db.commit() + + return payment + + except stripe.error.StripeError as e: + logger.error(f"Stripe error confirming payment: {e}") + raise PaymentProcessingException(f"Payment confirmation failed: {str(e)}") + + def create_store_stripe_account(self, store_id: int, store_data: Dict) -> str: + """Create Stripe Connect account for store.""" + + try: + # Create Stripe Connect Express account + account = stripe.Account.create( + type='express', + country='LU', # Luxembourg + email=store_data.get('business_email'), + capabilities={ + 'card_payments': {'requested': True}, + 'transfers': {'requested': True}, + }, + business_type='merchant', + merchant={ + 'name': store_data.get('business_name'), + 'phone': store_data.get('business_phone'), + 'address': { + 'line1': store_data.get('address_line1'), + 'city': store_data.get('city'), + 'postal_code': store_data.get('postal_code'), + 'country': 'LU' + } + }, + metadata={ + 'store_id': str(store_id), + 'platform': 'multi_tenant_ecommerce' + } + ) + + # Update or create payment configuration + payment_config = self.get_or_create_store_payment_config(store_id) + payment_config.stripe_account_id = account.id + payment_config.stripe_account_status = account.charges_enabled and account.payouts_enabled and 'active' or 'pending' + + self.db.commit() + + return account.id + + except stripe.error.StripeError as e: + logger.error(f"Stripe error creating account: {e}") + raise PaymentConfigurationException(f"Failed to create payment account: {str(e)}") + + def create_onboarding_link(self, store_id: int) -> str: + """Create Stripe onboarding link for store.""" + + payment_config = self.get_store_payment_config(store_id) + if not payment_config.stripe_account_id: + raise PaymentNotConfiguredException("Store does not have Stripe account") + + try: + account_link = stripe.AccountLink.create( + account=payment_config.stripe_account_id, + refresh_url=f"{settings.frontend_url}/store/admin/payments/refresh", + return_url=f"{settings.frontend_url}/store/admin/payments/success", + type='account_onboarding', + ) + + # Update onboarding URL + payment_config.stripe_onboarding_url = account_link.url + self.db.commit() + + return account_link.url + + except stripe.error.StripeError as e: + logger.error(f"Stripe error creating onboarding link: {e}") + raise PaymentConfigurationException(f"Failed to create onboarding link: {str(e)}") + + def get_store_payment_config(self, store_id: int) -> StorePaymentConfig: + """Get store payment configuration.""" + config = self.db.query(StorePaymentConfig).filter( + StorePaymentConfig.store_id == store_id + ).first() + + if not config: + raise PaymentNotConfiguredException(f"No payment configuration for store {store_id}") + + return config + + def webhook_handler(self, event_type: str, event_data: Dict) -> None: + """Handle Stripe webhook events.""" + + if event_type == 'payment_intent.succeeded': + payment_intent_id = event_data['object']['id'] + self.confirm_payment(payment_intent_id) + + elif event_type == 'payment_intent.payment_failed': + payment_intent_id = event_data['object']['id'] + self.confirm_payment(payment_intent_id) + + elif event_type == 'account.updated': + # Update store account status + account_id = event_data['object']['id'] + self.update_store_account_status(account_id, event_data['object']) + + # Add more webhook handlers as needed +``` + +## API Endpoints + +### Payment APIs + +```python +# app/api/v1/store/payments.py +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app.core.database import get_db +from middleware.store_context import require_store_context +from models.database.store import Store +from services.payment_service import PaymentService + +router = APIRouter(prefix="/payments", tags=["store-payments"]) + + +@router.get("/config") +async def get_payment_config( + store: Store = Depends(require_store_context()), + db: Session = Depends(get_db) +): + """Get store payment configuration.""" + payment_service = PaymentService(db) + + try: + config = payment_service.get_store_payment_config(store.id) + return { + "stripe_account_id": config.stripe_account_id, + "account_status": config.stripe_account_status, + "accepts_payments": config.accepts_payments, + "currency": config.currency, + "platform_fee_percentage": float(config.platform_fee_percentage), + "needs_onboarding": config.stripe_account_status != 'active' + } + except Exception: + return { + "stripe_account_id": None, + "account_status": "not_configured", + "accepts_payments": False, + "needs_setup": True + } + + +@router.post("/setup") +async def setup_payments( + setup_data: dict, + store: Store = Depends(require_store_context()), + db: Session = Depends(get_db) +): + """Set up Stripe payments for store.""" + payment_service = PaymentService(db) + + store_data = { + "business_name": store.name, + "business_email": store.business_email, + "business_phone": store.business_phone, + **setup_data + } + + account_id = payment_service.create_store_stripe_account(store.id, store_data) + onboarding_url = payment_service.create_onboarding_link(store.id) + + return { + "stripe_account_id": account_id, + "onboarding_url": onboarding_url, + "message": "Payment setup initiated. Complete onboarding to accept payments." + } + + +# app/api/v1/platform/stores/payments.py +@router.post("/{store_id}/payments/create-intent") +async def create_payment_intent( + store_id: int, + payment_data: dict, + db: Session = Depends(get_db) +): + """Create payment intent for customer order.""" + payment_service = PaymentService(db) + + payment_intent = payment_service.create_payment_intent( + store_id=store_id, + order_id=payment_data['order_id'], + amount_euros=Decimal(str(payment_data['amount'])), + customer_email=payment_data['customer_email'], + metadata=payment_data.get('metadata', {}) + ) + + return payment_intent + + +@router.post("/webhooks/stripe") +async def stripe_webhook( + request: Request, + db: Session = Depends(get_db) +): + """Handle Stripe webhook events.""" + import stripe + + payload = await request.body() + sig_header = request.headers.get('stripe-signature') + + try: + event = stripe.Webhook.construct_event( + payload, sig_header, settings.stripe_webhook_secret + ) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid payload") + except stripe.error.SignatureVerificationError: + raise HTTPException(status_code=400, detail="Invalid signature") + + payment_service = PaymentService(db) + payment_service.webhook_handler(event['type'], event['data']) + + return {"status": "success"} +``` + +## Frontend Integration + +### Checkout Process + +```javascript +// frontend/js/storefront/checkout.js +class CheckoutManager { + constructor(storeId) { + this.storeId = storeId; + this.stripe = Stripe(STRIPE_PUBLISHABLE_KEY); + this.elements = this.stripe.elements(); + this.paymentElement = null; + } + + async initializePayment(orderData) { + // Create payment intent + const response = await fetch(`/api/v1/platform/stores/${this.storeId}/payments/create-intent`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + order_id: orderData.orderId, + amount: orderData.total, + customer_email: orderData.customerEmail + }) + }); + + const { client_secret, amount_total, platform_fee } = await response.json(); + + // Display payment breakdown + this.displayPaymentBreakdown(amount_total, platform_fee); + + // Create payment element + this.paymentElement = this.elements.create('payment', { + clientSecret: client_secret + }); + + this.paymentElement.mount('#payment-element'); + } + + async confirmPayment(orderData) { + const { error } = await this.stripe.confirmPayment({ + elements: this.elements, + confirmParams: { + return_url: `${window.location.origin}/storefront/order-confirmation`, + receipt_email: orderData.customerEmail + } + }); + + if (error) { + this.showPaymentError(error.message); + } + } +} +``` + +## Updated Workflow Integration + +### Enhanced Customer Purchase Workflow + +``` +Customer adds products to cart + ↓ +Customer proceeds to checkout + ↓ +System creates Order (payment_status: pending) + ↓ +Frontend calls POST /api/v1/platform/stores/{store_id}/payments/create-intent + ↓ +PaymentService creates Stripe PaymentIntent with store destination + ↓ +Customer completes payment with Stripe Elements + ↓ +Stripe webhook confirms payment + ↓ +PaymentService updates Order (payment_status: paid, status: processing) + ↓ +Store receives order for fulfillment +``` + +### Payment Configuration Workflow + +``` +Store accesses payment settings + ↓ +POST /api/v1/store/payments/setup + ↓ +System creates Stripe Connect account + ↓ +Store completes Stripe onboarding + ↓ +Webhook updates account status to 'active' + ↓ +Store can now accept payments +``` + +This integration provides secure, compliant payment processing while maintaining store isolation and enabling proper revenue distribution between stores and the platform. diff --git a/app/modules/billing/docs/subscription-system.md b/app/modules/billing/docs/subscription-system.md new file mode 100644 index 00000000..31d2ce9c --- /dev/null +++ b/app/modules/billing/docs/subscription-system.md @@ -0,0 +1,182 @@ +# Subscription & Billing System + +The platform provides a comprehensive subscription and billing system for managing merchant subscriptions, feature-based usage limits, and payments through Stripe. + +## Overview + +The billing system enables: + +- **Subscription Tiers**: Database-driven tier definitions with configurable feature limits +- **Feature Provider Pattern**: Modules declare features and usage via `FeatureProviderProtocol`, aggregated by `FeatureAggregatorService` +- **Dynamic Usage Tracking**: Quantitative features (orders, products, team members) tracked per merchant with dynamic limits from `TierFeatureLimit` +- **Binary Feature Gating**: Toggle-based features (analytics, API access, white-label) controlled per tier +- **Merchant-Level Billing**: Subscriptions are per merchant+platform, not per store +- **Stripe Integration**: Checkout sessions, customer portal, and webhook handling +- **Add-ons**: Optional purchasable items (domains, SSL, email packages) +- **Capacity Forecasting**: Growth trends and scaling recommendations +- **Background Jobs**: Automated subscription lifecycle management + +## Architecture + +### Key Concepts + +The billing system uses a **feature provider pattern** where: + +1. **`TierFeatureLimit`** replaces hardcoded tier columns (`orders_per_month`, `products_limit`, `team_members`). Each feature limit is a row linking a tier to a feature code with a `limit_value`. +2. **`MerchantFeatureOverride`** provides per-merchant exceptions to tier defaults. +3. **Module feature providers** implement `FeatureProviderProtocol` to supply current usage data. +4. **`FeatureAggregatorService`** collects usage from all providers and combines it with tier limits to produce `FeatureSummary` records. + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Frontend Page Request │ +│ (Store Billing, Admin Subscriptions, Admin Store Detail) │ +└──────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ FeatureAggregatorService │ +│ (app/modules/billing/services/feature_service.py) │ +│ │ +│ • Collects feature providers from all enabled modules │ +│ • Queries TierFeatureLimit for limit values │ +│ • Queries MerchantFeatureOverride for per-merchant limits │ +│ • Calls provider.get_current_usage() for live counts │ +│ • Returns FeatureSummary[] with current/limit/percentage │ +└──────────────────────────────────────────────────────────────┘ + │ │ │ + ▼ ▼ ▼ + ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ + │ catalog module │ │ orders module │ │ tenancy module │ + │ products count │ │ orders count │ │ team members │ + └────────────────┘ └────────────────┘ └────────────────┘ +``` + +### Feature Types + +| Type | Description | Example | +|------|-------------|---------| +| **Quantitative** | Has a numeric limit with usage tracking | `max_products` (limit: 200, current: 150) | +| **Binary** | Toggle-based, either enabled or disabled | `analytics_dashboard` (enabled/disabled) | + +### FeatureSummary Dataclass + +```python +@dataclass +class FeatureSummary: + code: str # e.g., "max_products" + name_key: str # i18n key for display name + limit: int | None # None = unlimited + current: int # Current usage count + remaining: int # Remaining before limit + percent_used: float # 0.0 to 100.0 + feature_type: str # "quantitative" or "binary" + scope: str # "tier" or "merchant_override" +``` + +### Services + +| Service | Purpose | +|---------|---------| +| `FeatureAggregatorService` | Aggregates usage from module providers, resolves tier limits + overrides | +| `BillingService` | Subscription operations, checkout, portal | +| `SubscriptionService` | Subscription CRUD, tier lookups | +| `AdminSubscriptionService` | Admin subscription management | +| `StripeService` | Core Stripe API operations | +| `CapacityForecastService` | Growth trends, projections | + +### Background Tasks + +| Task | Schedule | Purpose | +|------|----------|---------| +| `reset_period_counters` | Daily | Reset order counters at period end | +| `check_trial_expirations` | Daily | Expire trials without payment method | +| `sync_stripe_status` | Hourly | Sync status with Stripe | +| `cleanup_stale_subscriptions` | Weekly | Clean up old cancelled subscriptions | +| `capture_capacity_snapshot` | Daily | Capture capacity metrics snapshot | + +## API Endpoints + +### Store Billing API (`/api/v1/store/billing`) + +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/subscription` | GET | Current subscription status | +| `/tiers` | GET | Available tiers for upgrade | +| `/usage` | GET | Dynamic usage metrics (from feature providers) | +| `/checkout` | POST | Create Stripe checkout session | +| `/portal` | POST | Create Stripe customer portal session | +| `/invoices` | GET | Invoice history | +| `/change-tier` | POST | Upgrade/downgrade tier | +| `/addons` | GET | Available add-on products | +| `/my-addons` | GET | Store's purchased add-ons | +| `/addons/purchase` | POST | Purchase an add-on | +| `/cancel` | POST | Cancel subscription | +| `/reactivate` | POST | Reactivate cancelled subscription | + +### Admin Subscription API (`/api/v1/admin/subscriptions`) + +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/tiers` | GET/POST | List/create tiers | +| `/tiers/{code}` | PATCH/DELETE | Update/delete tier | +| `/stats` | GET | Subscription statistics | +| `/merchants/{id}/platforms/{pid}` | GET/PUT | Get/update merchant subscription | +| `/store/{store_id}` | GET | Convenience: subscription + usage for a store | + +### Admin Feature Management API (`/api/v1/admin/subscriptions/features`) + +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/catalog` | GET | Feature catalog grouped by category | +| `/tiers/{code}/limits` | GET/PUT | Get/upsert feature limits for a tier | +| `/merchants/{id}/overrides` | GET/PUT | Get/upsert merchant feature overrides | + +## Subscription Tiers + +Tiers are stored in `subscription_tiers` with feature limits in `tier_feature_limits`: + +``` +SubscriptionTier (essential) + ├── TierFeatureLimit: max_products = 200 + ├── TierFeatureLimit: max_orders_per_month = 100 + ├── TierFeatureLimit: max_team_members = 1 + └── TierFeatureLimit: basic_analytics (binary, enabled) + +SubscriptionTier (professional) + ├── TierFeatureLimit: max_products = NULL (unlimited) + ├── TierFeatureLimit: max_orders_per_month = 500 + ├── TierFeatureLimit: max_team_members = 3 + └── TierFeatureLimit: analytics_dashboard (binary, enabled) +``` + +## Add-ons + +| Code | Name | Category | Price | +|------|------|----------|-------| +| `domain` | Custom Domain | domain | €15/year | +| `ssl_premium` | Premium SSL | ssl | €49/year | +| `email_5` | 5 Email Addresses | email | €5/month | +| `email_10` | 10 Email Addresses | email | €9/month | +| `email_25` | 25 Email Addresses | email | €19/month | + +## Exception Handling + +| Exception | HTTP | Description | +|-----------|------|-------------| +| `PaymentSystemNotConfiguredException` | 503 | Stripe not configured | +| `TierNotFoundException` | 404 | Invalid tier code | +| `StripePriceNotConfiguredException` | 400 | No Stripe price for tier | +| `NoActiveSubscriptionException` | 400 | Operation requires subscription | +| `SubscriptionNotCancelledException` | 400 | Cannot reactivate active subscription | + +## Related Documentation + +- [Data Model](data-model.md) — Entity relationships +- [Feature Gating](feature-gating.md) — Feature access control and UI integration +- [Stripe Integration](stripe-integration.md) — Payment setup +- [Tier Management](tier-management.md) — Admin guide for tier management +- [Subscription Workflow](subscription-workflow.md) — Subscription lifecycle +- [Metrics Provider Pattern](../../architecture/metrics-provider-pattern.md) — Protocol-based metrics +- [Capacity Monitoring](../../operations/capacity-monitoring.md) — Monitoring guide +- [Capacity Planning](../../architecture/capacity-planning.md) — Infrastructure sizing diff --git a/app/modules/billing/docs/subscription-workflow.md b/app/modules/billing/docs/subscription-workflow.md new file mode 100644 index 00000000..94a66bc6 --- /dev/null +++ b/app/modules/billing/docs/subscription-workflow.md @@ -0,0 +1,454 @@ +# Subscription Workflow Plan + +## Overview + +End-to-end subscription management workflow for stores on the platform. + +--- + +## 1. Store Subscribes to a Tier + +### 1.1 New Store Registration Flow + +``` +Store Registration → Select Tier → Trial Period → Payment Setup → Active Subscription +``` + +**Steps:** +1. Store creates account (existing flow) +2. During onboarding, store selects a tier: + - Show tier comparison cards (Essential, Professional, Business, Enterprise) + - Highlight features and limits for each tier + - Default to 14-day trial on selected tier +3. Create `StoreSubscription` record with: + - `tier` = selected tier code + - `status` = "trial" + - `trial_ends_at` = now + 14 days + - `period_start` / `period_end` set for trial period +4. Before trial ends, prompt store to add payment method +5. On payment method added → Create Stripe subscription → Status becomes "active" + +### 1.2 Database Changes Required + +**Add FK relationship to `subscription_tiers`:** +```python +# StoreSubscription - Add proper FK +tier_id = Column(Integer, ForeignKey("subscription_tiers.id"), nullable=True) +tier_code = Column(String(20), nullable=False) # Keep for backwards compat + +# Relationship +tier_obj = relationship("SubscriptionTier", backref="subscriptions") +``` + +**Migration:** +1. Add `tier_id` column (nullable initially) +2. Populate `tier_id` from existing `tier` code values +3. Add FK constraint + +### 1.3 API Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/v1/store/subscription/tiers` | GET | List available tiers for selection | +| `/api/v1/store/subscription/select-tier` | POST | Select tier during onboarding | +| `/api/v1/store/subscription/setup-payment` | POST | Create Stripe checkout for payment | + +--- + +## 2. Admin Views Subscription on Store Page + +### 2.1 Store Detail Page Enhancement + +**Location:** `/admin/stores/{store_id}` + +**New Subscription Card:** +``` +┌─────────────────────────────────────────────────────────────┐ +│ Subscription [Edit] │ +├─────────────────────────────────────────────────────────────┤ +│ Tier: Professional Status: Active │ +│ Price: €99/month Since: Jan 15, 2025 │ +│ Next Billing: Feb 15, 2025 │ +├─────────────────────────────────────────────────────────────┤ +│ Usage This Period │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Orders │ │ Products │ │ Team Members │ │ +│ │ 234 / 500 │ │ 156 / ∞ │ │ 2 / 3 │ │ +│ │ ████████░░ │ │ ████████████ │ │ ██████░░░░ │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +├─────────────────────────────────────────────────────────────┤ +│ Add-ons: Custom Domain (mydomain.com), 5 Email Addresses │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 2.2 Files to Modify + +- `app/templates/admin/store-detail.html` - Add subscription card +- `static/admin/js/store-detail.js` - Load subscription data +- `app/api/v1/admin/stores.py` - Include subscription in store response + +### 2.3 Admin Quick Actions + +From the store page, admin can: +- **Change Tier** - Upgrade/downgrade store +- **Override Limits** - Set custom limits (enterprise deals) +- **Extend Trial** - Give more trial days +- **Cancel Subscription** - With reason +- **Manage Add-ons** - Add/remove add-ons + +--- + +## 3. Tier Upgrade/Downgrade + +### 3.1 Admin-Initiated Change + +**Location:** Admin store page → Subscription card → [Edit] button + +**Modal: Change Subscription Tier** +``` +┌─────────────────────────────────────────────────────────┐ +│ Change Subscription Tier [X] │ +├─────────────────────────────────────────────────────────┤ +│ Current: Professional (€99/month) │ +│ │ +│ New Tier: │ +│ ○ Essential (€49/month) - Downgrade │ +│ ● Business (€199/month) - Upgrade │ +│ ○ Enterprise (Custom) - Contact required │ +│ │ +│ When to apply: │ +│ ○ Immediately (prorate current period) │ +│ ● At next billing cycle (Feb 15, 2025) │ +│ │ +│ [ ] Notify store by email │ +│ │ +│ [Cancel] [Apply Change] │ +└─────────────────────────────────────────────────────────┘ +``` + +### 3.2 Store-Initiated Change + +**Location:** Store dashboard → Billing page → [Change Plan] + +**Flow:** +1. Store clicks "Change Plan" on billing page +2. Shows tier comparison with current tier highlighted +3. Store selects new tier +4. For upgrades: + - Show prorated amount for immediate change + - Or option to change at next billing + - Redirect to Stripe checkout if needed +5. For downgrades: + - Always schedule for next billing cycle + - Show what features they'll lose + - Confirmation required + +### 3.3 API Endpoints + +| Endpoint | Method | Actor | Description | +|----------|--------|-------|-------------| +| `/api/v1/admin/subscriptions/{store_id}/change-tier` | POST | Admin | Change store's tier | +| `/api/v1/store/billing/change-tier` | POST | Store | Request tier change | +| `/api/v1/store/billing/preview-change` | POST | Store | Preview proration | + +### 3.4 Stripe Integration + +**Upgrade (Immediate):** +```python +stripe.Subscription.modify( + subscription_id, + items=[{"price": new_price_id}], + proration_behavior="create_prorations" +) +``` + +**Downgrade (Scheduled):** +```python +stripe.Subscription.modify( + subscription_id, + items=[{"price": new_price_id}], + proration_behavior="none", + billing_cycle_anchor="unchanged" +) +# Store scheduled change in our DB +``` + +--- + +## 4. Add-ons Upselling + +### 4.1 Where Add-ons Are Displayed + +#### A. Store Billing Page +``` +/store/{code}/billing + +┌─────────────────────────────────────────────────────────────┐ +│ Available Add-ons │ +├─────────────────────────────────────────────────────────────┤ +│ ┌─────────────────────┐ ┌─────────────────────┐ │ +│ │ 🌐 Custom Domain │ │ 📧 Email Package │ │ +│ │ €15/year │ │ From €5/month │ │ +│ │ Use your own domain │ │ 5, 10, or 25 emails │ │ +│ │ [Add to Plan] │ │ [Add to Plan] │ │ +│ └─────────────────────┘ └─────────────────────┘ │ +│ │ +│ ┌─────────────────────┐ ┌─────────────────────┐ │ +│ │ 🔒 Premium SSL │ │ 💾 Extra Storage │ │ +│ │ €49/year │ │ €5/month per 10GB │ │ +│ │ EV certificate │ │ More product images │ │ +│ │ [Add to Plan] │ │ [Add to Plan] │ │ +│ └─────────────────────┘ └─────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +#### B. Contextual Upsells + +**When store hits a limit:** +``` +┌─────────────────────────────────────────────────────────┐ +│ ⚠️ You've reached your order limit for this month │ +│ │ +│ Upgrade to Professional to get 500 orders/month │ +│ [Upgrade Now] [Dismiss] │ +└─────────────────────────────────────────────────────────┘ +``` + +**In settings when configuring domain:** +``` +┌─────────────────────────────────────────────────────────┐ +│ 🌐 Custom Domain │ +│ │ +│ Your shop is available at: myshop.platform.com │ +│ │ +│ Want to use your own domain like www.myshop.com? │ +│ Add the Custom Domain add-on for just €15/year │ +│ │ +│ [Add Custom Domain] │ +└─────────────────────────────────────────────────────────┘ +``` + +#### C. Upgrade Prompts in Tier Comparison + +When showing tier comparison, highlight what add-ons come included: +- Professional: Includes 1 custom domain +- Business: Includes custom domain + 5 email addresses +- Enterprise: All add-ons included + +### 4.2 Add-on Purchase Flow + +``` +Store clicks [Add to Plan] + ↓ +Modal: Configure Add-on + - Domain: Enter domain name, check availability + - Email: Select package (5/10/25) + ↓ +Create Stripe checkout session for add-on price + ↓ +On success: Create StoreAddOn record + ↓ +Provision add-on (domain registration, email setup) +``` + +### 4.3 Add-on Management + +**Store can view/manage in Billing page:** +``` +┌─────────────────────────────────────────────────────────────┐ +│ Your Add-ons │ +├─────────────────────────────────────────────────────────────┤ +│ Custom Domain myshop.com €15/year [Manage] │ +│ Email Package 5 addresses €5/month [Manage] │ +│ │ +│ Next billing: Feb 15, 2025 │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 4.4 Database: `store_addons` Table + +```python +class StoreAddOn(Base): + id = Column(Integer, primary_key=True) + store_id = Column(Integer, ForeignKey("stores.id")) + addon_product_id = Column(Integer, ForeignKey("addon_products.id")) + + # Config (e.g., domain name, email count) + config = Column(JSON, nullable=True) + + # Stripe + stripe_subscription_item_id = Column(String(100)) + + # Status + status = Column(String(20)) # active, cancelled, pending_setup + provisioned_at = Column(DateTime) + + # Billing + quantity = Column(Integer, default=1) + + created_at = Column(DateTime) + cancelled_at = Column(DateTime, nullable=True) +``` + +--- + +## 5. Implementation Phases + +**Last Updated:** December 31, 2025 + +### Phase 1: Database & Core (COMPLETED) +- [x] Add `tier_id` FK to StoreSubscription +- [x] Create migration with data backfill +- [x] Update subscription service to use tier relationship +- [x] Update admin subscription endpoints +- [x] **NEW:** Add Feature model with 30 features across 8 categories +- [x] **NEW:** Create FeatureService with caching for tier-based feature checking +- [x] **NEW:** Add UsageService for limit tracking and upgrade recommendations + +### Phase 2: Admin Store Page (PARTIALLY COMPLETE) +- [x] Add subscription card to store detail page +- [x] Show usage meters (orders, products, team) +- [ ] Add "Edit Subscription" modal +- [ ] Implement tier change API (admin) +- [x] **NEW:** Add Admin Features page (`/admin/features`) +- [x] **NEW:** Admin features API (list, update, toggle) + +### Phase 3: Store Billing Page (COMPLETED) +- [x] Create `/store/{code}/billing` page +- [x] Show current plan and usage +- [x] Add tier comparison/change UI +- [x] Implement tier change API (store) +- [x] Add Stripe checkout integration for upgrades +- [x] **NEW:** Add feature gate macros for templates +- [x] **NEW:** Add Alpine.js feature store +- [x] **NEW:** Add Alpine.js upgrade prompts store +- [x] **FIX:** Resolved 89 JS architecture violations (JS-005 through JS-009) + +### Phase 4: Add-ons (COMPLETED) +- [x] Seed add-on products in database +- [x] Add "Available Add-ons" section to billing page +- [x] Implement add-on purchase flow +- [x] Create StoreAddOn management (via billing page) +- [x] Add contextual upsell prompts +- [x] **FIX:** Fix Stripe webhook to create StoreAddOn records + +### Phase 5: Polish & Testing (IN PROGRESS) +- [ ] Email notifications for tier changes +- [x] Webhook handling for Stripe events +- [x] Usage limit enforcement updates +- [ ] End-to-end testing (manual testing required) +- [x] Documentation (feature-gating-system.md created) + +### Phase 6: Remaining Work (NEW) +- [ ] Admin tier change modal (upgrade/downgrade stores) +- [ ] Admin subscription override UI (custom limits for enterprise) +- [ ] Trial extension from admin panel +- [ ] Email notifications for tier changes +- [ ] Email notifications for approaching limits +- [ ] Grace period handling for failed payments +- [ ] Integration tests for full billing workflow +- [ ] Stripe test mode checkout verification + +--- + +## 6. Files Created/Modified + +**Last Updated:** December 31, 2025 + +### New Files (Created) +| File | Purpose | Status | +|------|---------|--------| +| `app/templates/store/billing.html` | Store billing page | DONE | +| `static/store/js/billing.js` | Billing page JS | DONE | +| `app/api/v1/store/billing.py` | Store billing endpoints | DONE | +| `models/database/feature.py` | Feature & StoreFeatureOverride models | DONE | +| `app/services/feature_service.py` | Feature access control service | DONE | +| `app/services/usage_service.py` | Usage tracking & limits service | DONE | +| `app/core/feature_gate.py` | @require_feature decorator & dependency | DONE | +| `app/api/v1/store/features.py` | Store features API | DONE | +| `app/api/v1/store/usage.py` | Store usage API | DONE | +| `app/api/v1/admin/features.py` | Admin features API | DONE | +| `app/templates/admin/features.html` | Admin features management page | DONE | +| `app/templates/shared/macros/feature_gate.html` | Jinja2 feature gate macros | DONE | +| `static/shared/js/feature-store.js` | Alpine.js feature store | DONE | +| `static/shared/js/upgrade-prompts.js` | Alpine.js upgrade prompts | DONE | +| `alembic/versions/n2c3d4e5f6a7_add_features_table.py` | Features migration | DONE | +| `docs/implementation/feature-gating-system.md` | Feature gating documentation | DONE | + +### Modified Files +| File | Changes | Status | +|------|---------|--------| +| `models/database/subscription.py` | Add tier_id FK | DONE | +| `models/database/__init__.py` | Export Feature models | DONE | +| `app/templates/admin/store-detail.html` | Add subscription card | DONE | +| `static/admin/js/store-detail.js` | Load subscription data | DONE | +| `app/api/v1/admin/stores.py` | Include subscription in response | DONE | +| `app/api/v1/admin/__init__.py` | Register features router | DONE | +| `app/api/v1/store/__init__.py` | Register features/usage routers | DONE | +| `app/services/subscription_service.py` | Tier change logic | DONE | +| `app/templates/store/partials/sidebar.html` | Add Billing link | DONE | +| `app/templates/store/base.html` | Load feature/upgrade stores | DONE | +| `app/templates/store/dashboard.html` | Add tier badge & usage bars | DONE | +| `app/handlers/stripe_webhook.py` | Create StoreAddOn on purchase | DONE | +| `app/routes/admin_pages.py` | Add features page route | DONE | +| `static/shared/js/api-client.js` | Add postFormData() & getBlob() | DONE | + +### Architecture Fixes (48 files) +| Rule | Files Fixed | Description | +|------|-------------|-------------| +| JS-003 | billing.js | Rename billingData→storeBilling | +| JS-005 | 15 files | Add init guards | +| JS-006 | 39 files | Add try/catch to async init | +| JS-008 | 5 files | Use apiClient not fetch | +| JS-009 | 30 files | Use Utils.showToast | +| TPL-009 | validate_architecture.py | Check store templates too | + +--- + +## 7. API Summary + +### Admin APIs +``` +GET /admin/stores/{id} # Includes subscription +POST /admin/subscriptions/{store_id}/change-tier +POST /admin/subscriptions/{store_id}/override-limits +POST /admin/subscriptions/{store_id}/extend-trial +POST /admin/subscriptions/{store_id}/cancel +``` + +### Store APIs +``` +GET /store/billing/subscription # Current subscription +GET /store/billing/tiers # Available tiers +POST /store/billing/preview-change # Preview tier change +POST /store/billing/change-tier # Request tier change +POST /store/billing/checkout # Stripe checkout session + +GET /store/billing/addons # Available add-ons +GET /store/billing/my-addons # Store's add-ons +POST /store/billing/addons/purchase # Purchase add-on +DELETE /store/billing/addons/{id} # Cancel add-on +``` + +--- + +## 8. Questions to Resolve + +1. **Trial without payment method?** + - Allow full trial without card, or require card upfront? + +2. **Downgrade handling:** + - What happens if store has more products than new tier allows? + - Block downgrade, or just prevent new products? + +3. **Enterprise tier:** + - Self-service or contact sales only? + - Custom pricing in UI or hidden? + +4. **Add-on provisioning:** + - Domain: Use reseller API or manual process? + - Email: Integrate with email provider or manual? + +5. **Grace period:** + - How long after payment failure before suspension? + - What gets disabled first? diff --git a/app/modules/billing/docs/tier-management.md b/app/modules/billing/docs/tier-management.md new file mode 100644 index 00000000..dd399a8f --- /dev/null +++ b/app/modules/billing/docs/tier-management.md @@ -0,0 +1,135 @@ +# Subscription Tier Management + +This guide explains how to manage subscription tiers and assign features to them in the admin panel. + +## Accessing Tier Management + +Navigate to **Admin → Billing & Subscriptions → Subscription Tiers** or go directly to `/admin/subscription-tiers`. + +## Dashboard Overview + +The tier management page displays: + +### Stats Cards +- **Total Tiers**: Number of configured subscription tiers +- **Active Tiers**: Tiers currently available for subscription +- **Public Tiers**: Tiers visible to stores (excludes enterprise/custom) +- **Est. MRR**: Estimated Monthly Recurring Revenue + +### Tier Table + +Each tier shows: + +| Column | Description | +|--------|-------------| +| # | Display order (affects pricing page order) | +| Code | Unique identifier (e.g., `essential`, `professional`) | +| Name | Display name shown to stores | +| Monthly | Monthly price in EUR | +| Annual | Annual price in EUR (or `-` if not set) | +| Orders/Mo | Monthly order limit (or `Unlimited`) | +| Products | Product limit (or `Unlimited`) | +| Team | Team member limit (or `Unlimited`) | +| Features | Number of features assigned | +| Status | Active, Private, or Inactive | +| Actions | Edit Features, Edit, Activate/Deactivate | + +## Managing Tiers + +### Creating a New Tier + +1. Click **Create Tier** button +2. Fill in the tier details: + - **Code**: Unique lowercase identifier (cannot be changed after creation) + - **Name**: Display name for the tier + - **Monthly Price**: Price in cents (e.g., 4900 for €49.00) + - **Annual Price**: Optional annual price in cents + - **Order Limit**: Leave empty for unlimited + - **Product Limit**: Leave empty for unlimited + - **Team Members**: Leave empty for unlimited + - **Display Order**: Controls sort order on pricing pages + - **Active**: Whether tier is available + - **Public**: Whether tier is visible to stores +3. Click **Create** + +### Editing a Tier + +1. Click the **pencil icon** on the tier row +2. Modify the tier properties +3. Click **Update** + +Note: The tier code cannot be changed after creation. + +### Activating/Deactivating Tiers + +- Click the **check-circle icon** to activate an inactive tier +- Click the **x-circle icon** to deactivate an active tier + +Deactivating a tier: +- Does not affect existing subscriptions +- Hides the tier from new subscription selection +- Can be reactivated at any time + +## Managing Features + +### Assigning Features to a Tier + +1. Click the **puzzle-piece icon** on the tier row +2. A slide-over panel opens showing all available features +3. Features are grouped by category: + - Analytics + - Product Management + - Order Management + - Marketing + - Support + - Integration + - Branding + - Team + +4. Check/uncheck features to include in the tier +5. Use **Select all** or **Deselect all** per category for bulk actions +6. The footer shows the total number of selected features +7. Click **Save Features** to apply changes + +### Feature Categories + +| Category | Example Features | +|----------|------------------| +| Analytics | Basic Analytics, Analytics Dashboard, Custom Reports | +| Product Management | Bulk Edit, Variants, Bundles, Inventory Alerts | +| Order Management | Order Automation, Advanced Fulfillment, Multi-Warehouse | +| Marketing | Discount Codes, Abandoned Cart, Email Marketing, Loyalty | +| Support | Email Support, Priority Support, Phone Support, Dedicated Manager | +| Integration | Basic API, Advanced API, Webhooks, Custom Integrations | +| Branding | Theme Customization, Custom Domain, White Label | +| Team | Team Management, Role Permissions, Audit Logs | + +## Best Practices + +### Tier Pricing Strategy + +1. **Essential**: Entry-level with basic features and limits +2. **Professional**: Mid-tier with increased limits and key integrations +3. **Business**: Full-featured for growing businesses +4. **Enterprise**: Custom pricing with unlimited everything + +### Feature Assignment Tips + +- Start with fewer features in lower tiers +- Ensure each upgrade tier adds meaningful value +- Keep support features as upgrade incentives +- API access typically belongs in Business+ tiers + +### Stripe Integration + +For each tier, you can optionally configure: +- **Stripe Product ID**: Link to Stripe product +- **Stripe Monthly Price ID**: Link to monthly price +- **Stripe Annual Price ID**: Link to annual price + +These are required for automated billing via Stripe Checkout. + +## Related Documentation + +- [Subscription & Billing System](subscription-system.md) - Complete billing documentation +- [Feature Gating System](feature-gating.md) - Technical feature gating details diff --git a/app/modules/cart/docs/index.md b/app/modules/cart/docs/index.md new file mode 100644 index 00000000..4e6d3ff8 --- /dev/null +++ b/app/modules/cart/docs/index.md @@ -0,0 +1,41 @@ +# Shopping Cart + +Session-based shopping cart for storefronts. + +## Overview + +| Aspect | Detail | +|--------|--------| +| Code | `cart` | +| Classification | Optional | +| Dependencies | `inventory`, `catalog` | +| Status | Active | + +## Features + +- `cart_management` — Cart creation and management +- `cart_persistence` — Session-based cart persistence +- `cart_item_operations` — Add/update/remove cart items +- `shipping_calculation` — Shipping cost calculation +- `promotion_application` — Apply promotions and discounts + +## Permissions + +| Permission | Description | +|------------|-------------| +| `cart.view` | View cart data | +| `cart.manage` | Manage cart settings | + +## Data Model + +- **Cart** — Shopping cart with session tracking and store scoping + +## API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `*` | `/api/v1/storefront/cart/*` | Storefront cart operations | + +## Configuration + +No module-specific configuration. diff --git a/app/modules/catalog/docs/architecture.md b/app/modules/catalog/docs/architecture.md new file mode 100644 index 00000000..75f5ed04 --- /dev/null +++ b/app/modules/catalog/docs/architecture.md @@ -0,0 +1,291 @@ +# Product Architecture + +## Overview + +The product management system uses an **independent copy pattern** where store products (`Product`) are fully independent entities that can optionally reference a marketplace source (`MarketplaceProduct`) for display purposes only. + +## Core Principles + +| Principle | Description | +|-----------|-------------| +| **Full Independence** | Store products have all their own fields - no inheritance or fallback to marketplace | +| **Optional Source Reference** | `marketplace_product_id` is nullable - products can be created directly | +| **No Reset Functionality** | No "reset to source" - products are independent from the moment of creation | +| **Source for Display Only** | Source comparison info is read-only, used for "view original" display | + +--- + +## Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ MarketplaceProduct │ +│ (Central Repository - raw imported data from marketplaces) │ +│ │ +│ - marketplace_product_id (unique) │ +│ - gtin, mpn, sku │ +│ - brand, price_cents, sale_price_cents │ +│ - is_digital, product_type_enum │ +│ - translations (via MarketplaceProductTranslation) │ +└──────────────────────────────────────────────────────────────────────┘ + + ╳ No runtime dependency + │ + │ Optional FK (for "view source" display only) + │ marketplace_product_id (nullable) + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Product │ +│ (Store's Independent Product - fully standalone) │ +│ │ +│ === IDENTIFIERS === │ +│ - store_id (required) │ +│ - store_sku │ +│ - gtin, gtin_type │ +│ │ +│ === PRODUCT TYPE (own columns) === │ +│ - is_digital (Boolean) │ +│ - product_type (String: physical, digital, service, subscription) │ +│ │ +│ === PRICING === │ +│ - price_cents, sale_price_cents │ +│ - currency, tax_rate_percent │ +│ │ +│ === CONTENT === │ +│ - brand, condition, availability │ +│ - primary_image_url, additional_images │ +│ - translations (via ProductTranslation) │ +│ │ +│ === STATUS === │ +│ - is_active, is_featured │ +│ │ +│ === SUPPLIER === │ +│ - supplier, cost_cents │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Product Creation Patterns + +### 1. From Marketplace Source (Import) + +When copying from a marketplace product: +- All fields are **copied** at creation time +- `marketplace_product_id` is set for source reference +- No ongoing relationship - product is immediately independent + +```python +# Service copies all fields at import time +product = Product( + store_id=store.id, + marketplace_product_id=marketplace_product.id, # Source reference + # All fields copied - no inheritance + brand=marketplace_product.brand, + price=marketplace_product.price, + is_digital=marketplace_product.is_digital, + product_type=marketplace_product.product_type_enum, + # ... all other fields +) +``` + +### 2. Direct Creation (No Marketplace Source) + +Stores can create products directly without a marketplace source: + +```python +product = Product( + store_id=store.id, + marketplace_product_id=None, # No source + store_sku="DIRECT_001", + brand="MyBrand", + price=29.99, + is_digital=True, + product_type="digital", + is_active=True, +) +``` + +--- + +## Key Fields + +### Product Type Fields + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `is_digital` | Boolean | `False` | Whether product is digital (no physical shipping) | +| `product_type` | String(20) | `"physical"` | Product type: physical, digital, service, subscription | + +These are **independent columns** on Product, not derived from MarketplaceProduct. + +### Source Reference + +| Field | Type | Nullable | Description | +|-------|------|----------|-------------| +| `marketplace_product_id` | Integer FK | **Yes** | Optional reference to source MarketplaceProduct | + +--- + +## Inventory Handling + +Digital and physical products have different inventory behavior: + +```python +@property +def has_unlimited_inventory(self) -> bool: + """Digital products have unlimited inventory.""" + return self.is_digital + +@property +def total_inventory(self) -> int: + """Get total inventory across all locations.""" + if self.is_digital: + return Product.UNLIMITED_INVENTORY # 999999 + return sum(inv.quantity for inv in self.inventory_entries) +``` + +--- + +## Source Comparison (Display Only) + +For products with a marketplace source, we provide comparison info for display: + +```python +def get_source_comparison_info(self) -> dict: + """Get current values with source values for comparison. + + Used for "view original source" display feature. + """ + mp = self.marketplace_product + return { + "price": self.price, + "price_source": mp.price if mp else None, + "brand": self.brand, + "brand_source": mp.brand if mp else None, + # ... other fields + } +``` + +This is **read-only** - there's no mechanism to "reset" to source values. + +--- + +## UI Behavior + +### Detail Page + +| Product Type | Source Info Card | Edit Button Text | +|-------------|------------------|------------------| +| Marketplace-sourced | Shows source info with "View Source" link | "Edit Overrides" | +| Directly created | Shows "Direct Creation" badge | "Edit Product" | + +### Info Banner + +- **Marketplace-sourced**: Purple banner - "Store Product Catalog Entry" +- **Directly created**: Blue banner - "Directly Created Product" + +--- + +## Database Schema + +### Product Table Key Columns + +```sql +CREATE TABLE products ( + id INTEGER PRIMARY KEY, + store_id INTEGER NOT NULL REFERENCES stores(id), + marketplace_product_id INTEGER REFERENCES marketplace_products(id), -- Nullable! + + -- Product Type (independent columns) + is_digital BOOLEAN DEFAULT FALSE, + product_type VARCHAR(20) DEFAULT 'physical', + + -- Identifiers + store_sku VARCHAR, + gtin VARCHAR, + gtin_type VARCHAR(10), + brand VARCHAR, + + -- Pricing (in cents) + price_cents INTEGER, + sale_price_cents INTEGER, + currency VARCHAR(3) DEFAULT 'EUR', + tax_rate_percent INTEGER DEFAULT 17, + availability VARCHAR, + + -- Media + primary_image_url VARCHAR, + additional_images JSON, + + -- Status + is_active BOOLEAN DEFAULT TRUE, + is_featured BOOLEAN DEFAULT FALSE, + + -- Timestamps + created_at TIMESTAMP, + updated_at TIMESTAMP +); + +-- Index for product type queries +CREATE INDEX idx_product_is_digital ON products(is_digital); +``` + +--- + +## Migration History + +| Migration | Description | +|-----------|-------------| +| `x2c3d4e5f6g7` | Made `marketplace_product_id` nullable | +| `y3d4e5f6g7h8` | Added `is_digital` and `product_type` columns to products | + +--- + +## API Endpoints + +### Create Product (Admin) + +``` +POST /api/v1/admin/store-products +{ + "store_id": 1, + "translations": { + "en": {"title": "Product Name", "description": "..."}, + "fr": {"title": "Nom du produit", "description": "..."} + }, + "store_sku": "SKU001", + "brand": "BrandName", + "price": 29.99, + "is_digital": false, + "is_active": true +} +``` + +### Update Product (Admin) + +``` +PATCH /api/v1/admin/store-products/{id} +{ + "is_digital": true, + "price": 39.99, + "translations": { + "en": {"title": "Updated Name"} + } +} +``` + +--- + +## Testing + +Key test scenarios: + +1. **Direct Product Creation** - Create without marketplace source +2. **Digital Product Inventory** - Verify unlimited inventory for digital +3. **is_digital Column** - Verify it's an independent column, not derived +4. **Source Comparison** - Verify read-only source info display + +See: +- `tests/unit/models/database/test_product.py` +- `tests/integration/api/v1/admin/test_store_products.py` diff --git a/app/modules/catalog/docs/data-model.md b/app/modules/catalog/docs/data-model.md new file mode 100644 index 00000000..973f63a5 --- /dev/null +++ b/app/modules/catalog/docs/data-model.md @@ -0,0 +1,105 @@ +# Catalog Data Model + +Entity relationships and database schema for the catalog module. + +## Entity Relationship Overview + +``` +Store 1──* Product 1──* ProductTranslation + │ + ├──* ProductMedia *──1 MediaFile + │ + ├──? MarketplaceProduct (source) + │ + └──* Inventory (from inventory module) +``` + +## Models + +### Product + +Store-specific product with independent copy pattern from marketplace imports. All monetary values stored as integer cents. + +| Field | Type | Constraints | Description | +|-------|------|-------------|-------------| +| `id` | Integer | PK | Primary key | +| `store_id` | Integer | FK, not null | Store ownership | +| `marketplace_product_id` | Integer | FK, nullable | Optional marketplace source | +| `store_sku` | String | indexed | Store's internal SKU | +| `gtin` | String(50) | indexed | EAN/UPC barcode | +| `gtin_type` | String(20) | nullable | gtin13, gtin14, gtin12, gtin8, isbn13, isbn10 | +| `price_cents` | Integer | nullable | Gross price in cents | +| `sale_price_cents` | Integer | nullable | Sale price in cents | +| `currency` | String(3) | default "EUR" | Currency code | +| `brand` | String | nullable | Product brand | +| `condition` | String | nullable | Product condition | +| `availability` | String | nullable | Availability status | +| `primary_image_url` | String | nullable | Main product image URL | +| `additional_images` | JSON | nullable | Array of additional image URLs | +| `download_url` | String | nullable | Digital product download URL | +| `license_type` | String(50) | nullable | Digital product license | +| `tax_rate_percent` | Integer | not null, default 17 | VAT rate (LU: 0, 3, 8, 14, 17) | +| `supplier` | String(50) | nullable | codeswholesale, internal, etc. | +| `supplier_product_id` | String | nullable | Supplier's product reference | +| `cost_cents` | Integer | nullable | Cost to acquire in cents | +| `margin_percent_x100` | Integer | nullable | Markup × 100 (2550 = 25.5%) | +| `is_digital` | Boolean | default False, indexed | Digital vs physical | +| `product_type` | String(20) | default "physical" | physical, digital, service, subscription | +| `is_featured` | Boolean | default False | Featured flag | +| `is_active` | Boolean | default True | Active flag | +| `display_order` | Integer | default 0 | Sort order | +| `min_quantity` | Integer | default 1 | Min purchase quantity | +| `max_quantity` | Integer | nullable | Max purchase quantity | +| `fulfillment_email_template` | String | nullable | Template for digital delivery | +| `created_at` | DateTime | tz-aware | Record creation time | +| `updated_at` | DateTime | tz-aware | Record update time | + +**Unique Constraint**: `(store_id, marketplace_product_id)` +**Composite Indexes**: `(store_id, is_active)`, `(store_id, is_featured)`, `(store_id, store_sku)`, `(supplier, supplier_product_id)` + +**Key Properties**: `price`, `sale_price`, `cost` (euro converters), `net_price_cents` (gross minus VAT), `vat_amount_cents`, `profit_cents`, `profit_margin_percent`, `total_inventory`, `available_inventory` + +### ProductTranslation + +Store-specific multilingual content with SEO fields. Independent copy from marketplace translations. + +| Field | Type | Constraints | Description | +|-------|------|-------------|-------------| +| `id` | Integer | PK | Primary key | +| `product_id` | Integer | FK, not null, cascade | Parent product | +| `language` | String(5) | not null | en, fr, de, lb | +| `title` | String | nullable | Product title | +| `description` | Text | nullable | Full description | +| `short_description` | String(500) | nullable | Abbreviated description | +| `meta_title` | String(70) | nullable | SEO title | +| `meta_description` | String(160) | nullable | SEO description | +| `url_slug` | String(255) | nullable | URL-friendly slug | +| `created_at` | DateTime | tz-aware | Record creation time | +| `updated_at` | DateTime | tz-aware | Record update time | + +**Unique Constraint**: `(product_id, language)` + +### ProductMedia + +Association between products and media files with usage tracking. + +| Field | Type | Constraints | Description | +|-------|------|-------------|-------------| +| `id` | Integer | PK | Primary key | +| `product_id` | Integer | FK, not null, cascade | Product reference | +| `media_id` | Integer | FK, not null, cascade | Media file reference | +| `usage_type` | String(50) | default "gallery" | main_image, gallery, variant, thumbnail, swatch | +| `display_order` | Integer | default 0 | Sort order | +| `variant_id` | Integer | nullable | Variant reference | +| `created_at` | DateTime | tz-aware | Record creation time | +| `updated_at` | DateTime | tz-aware | Record update time | + +**Unique Constraint**: `(product_id, media_id, usage_type)` + +## Design Patterns + +- **Independent copy pattern**: Products are copied from marketplace sources, not linked. Store-specific data diverges independently. +- **Money as cents**: All prices, costs, margins stored as integer cents +- **Luxembourg VAT**: Supports all LU rates (0%, 3%, 8%, 14%, 17%) +- **Multi-type products**: Physical, digital, service, subscription with type-specific fields +- **SEO per language**: Meta title and description in each translation diff --git a/app/modules/catalog/docs/index.md b/app/modules/catalog/docs/index.md new file mode 100644 index 00000000..17948327 --- /dev/null +++ b/app/modules/catalog/docs/index.md @@ -0,0 +1,57 @@ +# Product Catalog + +Product catalog browsing and search for storefronts. + +## Overview + +| Aspect | Detail | +|--------|--------| +| Code | `catalog` | +| Classification | Optional | +| Dependencies | None | +| Status | Active | + +## Features + +- `product_catalog` — Product catalog browsing +- `product_search` — Product search and filtering +- `product_variants` — Product variant management +- `product_categories` — Category hierarchy +- `product_attributes` — Custom product attributes +- `product_import_export` — Bulk product import/export + +## Permissions + +| Permission | Description | +|------------|-------------| +| `products.view` | View products | +| `products.create` | Create products | +| `products.edit` | Edit products | +| `products.delete` | Delete products | +| `products.import` | Import products | +| `products.export` | Export products | + +## Data Model + +See [Data Model](data-model.md) for full entity relationships and schema. + +- **Product** — Store-specific product with pricing, VAT, and supplier fields +- **ProductTranslation** — Multilingual content with SEO fields +- **ProductMedia** — Product-media associations with usage types + +## API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `*` | `/api/v1/admin/catalog/*` | Admin product management | +| `*` | `/api/v1/store/catalog/*` | Store product management | +| `GET` | `/api/v1/storefront/catalog/*` | Public product browsing | + +## Configuration + +No module-specific configuration. + +## Additional Documentation + +- [Data Model](data-model.md) — Entity relationships and database schema +- [Architecture](architecture.md) — Independent product copy pattern and API design diff --git a/app/modules/checkout/docs/index.md b/app/modules/checkout/docs/index.md new file mode 100644 index 00000000..f40e4fa2 --- /dev/null +++ b/app/modules/checkout/docs/index.md @@ -0,0 +1,41 @@ +# Checkout + +Checkout and order creation for storefronts. + +## Overview + +| Aspect | Detail | +|--------|--------| +| Code | `checkout` | +| Classification | Optional | +| Dependencies | `cart`, `orders`, `payments`, `customers` | +| Status | Active | + +## Features + +- `checkout_flow` — Multi-step checkout process +- `order_creation` — Cart-to-order conversion +- `payment_processing` — Payment integration during checkout +- `checkout_validation` — Address and cart validation +- `guest_checkout` — Checkout without customer account + +## Permissions + +| Permission | Description | +|------------|-------------| +| `checkout.view_settings` | View checkout settings | +| `checkout.manage_settings` | Manage checkout configuration | + +## Data Model + +Checkout is a stateless flow that creates orders — no dedicated models. + +## API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `*` | `/api/v1/storefront/checkout/*` | Storefront checkout flow | + +## Configuration + +No module-specific configuration. diff --git a/app/modules/cms/docs/architecture.md b/app/modules/cms/docs/architecture.md new file mode 100644 index 00000000..2650abc4 --- /dev/null +++ b/app/modules/cms/docs/architecture.md @@ -0,0 +1,604 @@ +# Content Management System (CMS) + +## Overview + +The Content Management System allows platform administrators and stores to manage static content pages like About, FAQ, Contact, Shipping, Returns, Privacy Policy, Terms of Service, etc. + +**Key Features:** +- ✅ Platform-level default content +- ✅ Store-specific overrides +- ✅ Fallback system (store → platform default) +- ✅ Rich text content (HTML/Markdown) +- ✅ SEO metadata +- ✅ Published/Draft status +- ✅ Navigation management (footer/header) +- ✅ Display order control + +## Architecture + +### Two-Tier Content System + +``` +┌─────────────────────────────────────────────────────────────┐ +│ CONTENT LOOKUP FLOW │ +└─────────────────────────────────────────────────────────────┘ + +Request: /about + + 1. Check for store-specific override + ↓ + SELECT * FROM content_pages + WHERE store_id = 123 AND slug = 'about' AND is_published = true + ↓ + Found? ✅ Return store content + ❌ Continue to step 2 + + 2. Check for platform default + ↓ + SELECT * FROM content_pages + WHERE store_id IS NULL AND slug = 'about' AND is_published = true + ↓ + Found? ✅ Return platform content + ❌ Return 404 or default template +``` + +### Database Schema + +```sql +CREATE TABLE content_pages ( + id SERIAL PRIMARY KEY, + + -- Store association (NULL = platform default) + store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE, + + -- Page identification + slug VARCHAR(100) NOT NULL, -- about, faq, contact, shipping, returns + title VARCHAR(200) NOT NULL, + + -- Content + content TEXT NOT NULL, -- HTML or Markdown + content_format VARCHAR(20) DEFAULT 'html', -- html, markdown + + -- SEO + meta_description VARCHAR(300), + meta_keywords VARCHAR(300), + + -- Publishing + is_published BOOLEAN DEFAULT FALSE NOT NULL, + published_at TIMESTAMP WITH TIME ZONE, + + -- Navigation placement + display_order INTEGER DEFAULT 0, + show_in_footer BOOLEAN DEFAULT TRUE, -- Quick Links column + show_in_header BOOLEAN DEFAULT FALSE, -- Top navigation + show_in_legal BOOLEAN DEFAULT FALSE, -- Bottom bar with copyright + + -- Timestamps + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL, + + -- Author tracking + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + + -- Constraints + CONSTRAINT uq_store_slug UNIQUE (store_id, slug) +); + +CREATE INDEX idx_store_published ON content_pages (store_id, is_published); +CREATE INDEX idx_slug_published ON content_pages (slug, is_published); +``` + +## Usage + +### Platform Administrator Workflow + +**1. Create Platform Default Pages** + +Platform admins create default content that all stores inherit: + +```bash +POST /api/v1/admin/content-pages/platform +{ + "slug": "about", + "title": "About Us", + "content": "

About Us

We are a marketplace...

", + "content_format": "html", + "meta_description": "Learn more about our marketplace", + "is_published": true, + "show_in_header": true, + "show_in_footer": true, + "show_in_legal": false, + "display_order": 1 +} +``` + +**Common Platform Defaults:** +- `about` - About Us +- `contact` - Contact Us +- `faq` - Frequently Asked Questions +- `shipping` - Shipping Information +- `returns` - Return Policy +- `privacy` - Privacy Policy +- `terms` - Terms of Service +- `help` - Help Center + +**2. View All Content Pages** + +```bash +GET /api/v1/admin/content-pages/ +GET /api/v1/admin/content-pages/?store_id=123 # Filter by store +GET /api/v1/admin/content-pages/platform # Only platform defaults +``` + +**3. Update Platform Default** + +```bash +PUT /api/v1/admin/content-pages/1 +{ + "title": "Updated About Us", + "content": "

About Our Platform

...", + "is_published": true +} +``` + +### Store Workflow + +**1. View Available Pages** + +Stores see their overrides + platform defaults: + +```bash +GET /api/v1/store/{code}/content-pages/ +``` + +Response: +```json +[ + { + "id": 15, + "slug": "about", + "title": "About Orion", // Store override + "is_store_override": true, + "is_platform_page": false + }, + { + "id": 2, + "slug": "shipping", + "title": "Shipping Information", // Platform default + "is_store_override": false, + "is_platform_page": true + } +] +``` + +**2. Create Store Override** + +Store creates custom "About" page: + +```bash +POST /api/v1/store/{code}/content-pages/ +{ + "slug": "about", + "title": "About Orion", + "content": "

About Orion

We specialize in...

", + "is_published": true +} +``` + +This overrides the platform default for this store only. + +**3. View Only Store Overrides** + +```bash +GET /api/v1/store/{code}/content-pages/overrides +``` + +Shows what the store has customized (excludes platform defaults). + +**4. Delete Override (Revert to Platform Default)** + +```bash +DELETE /api/v1/store/{code}/content-pages/15 +``` + +After deletion, platform default will be shown again. + +### Storefront (Public) + +**1. Get Page Content** + +```bash +GET /api/v1/storefront/content-pages/about +``` + +Automatically uses store context from middleware: +- Returns store override if exists +- Falls back to platform default +- Returns 404 if neither exists + +**2. Get Navigation Links** + +```bash +# Get all navigation pages +GET /api/v1/storefront/content-pages/navigation + +# Filter by placement +GET /api/v1/storefront/content-pages/navigation?header_only=true +GET /api/v1/storefront/content-pages/navigation?footer_only=true +GET /api/v1/storefront/content-pages/navigation?legal_only=true +``` + +Returns published pages filtered by navigation placement. + +## File Structure + +``` +app/ +├── models/database/ +│ └── content_page.py ← Database model +│ +├── services/ +│ └── content_page_service.py ← Business logic +│ +├── api/v1/ +│ ├── admin/ +│ │ └── content_pages.py ← Admin API endpoints +│ ├── store/ +│ │ └── content_pages.py ← Store API endpoints +│ └── storefront/ +│ └── content_pages.py ← Public API endpoints +│ +└── templates/storefront/ + ├── about.html ← Content page template + ├── faq.html + ├── contact.html + └── ... +``` + +## Template Integration + +### Generic Content Page Template + +Create a reusable template for all content pages: + +```jinja2 +{# app/templates/storefront/content-page.html #} +{% extends "storefront/base.html" %} + +{% block title %}{{ page.title }}{% endblock %} + +{% block meta_description %} + {{ page.meta_description or page.title }} +{% endblock %} + +{% block content %} +
+ + {# Breadcrumbs #} + + + {# Page Title #} +

+ {{ page.title }} +

+ + {# Content #} +
+ {% if page.content_format == 'markdown' %} + {{ page.content | markdown }} + {% else %} + {{ page.content | safe }} + {% endif %} +
+ + {# Last updated #} + {% if page.updated_at %} +
+ Last updated: {{ page.updated_at }} +
+ {% endif %} + +
+{% endblock %} +``` + +### Route Handler + +```python +# app/routes/storefront_pages.py + +from app.services.content_page_service import content_page_service + +@router.get("/{slug}", response_class=HTMLResponse) +async def content_page( + slug: str, + request: Request, + db: Session = Depends(get_db) +): + """ + Generic content page handler. + + Loads content from database with store override support. + """ + store = getattr(request.state, 'store', None) + store_id = store.id if store else None + + page = content_page_service.get_page_for_store( + db, + slug=slug, + store_id=store_id, + include_unpublished=False + ) + + if not page: + raise HTTPException(status_code=404, detail=f"Page not found: {slug}") + + return templates.TemplateResponse( + "storefront/content-page.html", + get_storefront_context(request, page=page) + ) +``` + +### Dynamic Footer Navigation + +Update footer to load links from database: + +```jinja2 +{# app/templates/storefront/base.html #} + + +``` + +## Best Practices + +### 1. Content Formatting + +**HTML Content:** +```html +

About Us

+

We are a leading marketplace for...

+
    +
  • Quality products
  • +
  • Fast shipping
  • +
  • Great support
  • +
+``` + +**Markdown Content:** +```markdown +# About Us + +We are a **leading marketplace** for... + +- Quality products +- Fast shipping +- Great support +``` + +### 2. SEO Optimization + +Always provide meta descriptions: + +```json +{ + "meta_description": "Learn about our marketplace, mission, and values. We connect stores with customers worldwide.", + "meta_keywords": "about us, marketplace, e-commerce, mission" +} +``` + +### 3. Draft → Published Workflow + +1. Create page with `is_published: false` +2. Preview using `include_unpublished=true` parameter +3. Review and edit +4. Publish with `is_published: true` + +### 4. Navigation Management + +The CMS supports three navigation placement categories: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ HEADER (show_in_header=true) │ +│ [Logo] About Us Contact [Login] [Sign Up] │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ PAGE CONTENT │ +│ │ +├─────────────────────────────────────────────────────────────────┤ +│ FOOTER (show_in_footer=true) │ +│ ┌──────────────┬──────────────┬────────────┬──────────────┐ │ +│ │ Quick Links │ Platform │ Contact │ Social │ │ +│ │ • About │ • Admin │ • Email │ • Twitter │ │ +│ │ • FAQ │ • Store │ • Phone │ • LinkedIn │ │ +│ │ • Contact │ │ │ │ │ +│ │ • Shipping │ │ │ │ │ +│ └──────────────┴──────────────┴────────────┴──────────────┘ │ +├─────────────────────────────────────────────────────────────────┤ +│ LEGAL BAR (show_in_legal=true) │ +│ © 2025 Orion Privacy Policy │ Terms │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Navigation Categories:** + +| Category | Field | Location | Typical Pages | +|----------|-------|----------|---------------| +| Header | `show_in_header` | Top navigation bar | About, Contact | +| Footer | `show_in_footer` | Quick Links column | FAQ, Shipping, Returns | +| Legal | `show_in_legal` | Bottom bar with © | Privacy, Terms | + +**Use `display_order` to control link ordering within each category:** + +```python +# Platform defaults with navigation placement +"about": display_order=1, show_in_header=True, show_in_footer=True +"contact": display_order=2, show_in_header=True, show_in_footer=True +"faq": display_order=3, show_in_footer=True +"shipping": display_order=4, show_in_footer=True +"returns": display_order=5, show_in_footer=True +"privacy": display_order=6, show_in_legal=True +"terms": display_order=7, show_in_legal=True +``` + +### 5. Content Reversion + +To revert store override back to platform default: + +```bash +# Store deletes their custom page +DELETE /api/v1/store/{code}/content-pages/15 + +# Platform default will now be shown automatically +``` + +## Common Page Slugs + +Standard slugs to implement: + +| Slug | Title | Header | Footer | Legal | Order | +|------|-------|--------|--------|-------|-------| +| `about` | About Us | ✅ | ✅ | ❌ | 1 | +| `contact` | Contact Us | ✅ | ✅ | ❌ | 2 | +| `faq` | FAQ | ❌ | ✅ | ❌ | 3 | +| `shipping` | Shipping Info | ❌ | ✅ | ❌ | 4 | +| `returns` | Returns | ❌ | ✅ | ❌ | 5 | +| `privacy` | Privacy Policy | ❌ | ❌ | ✅ | 6 | +| `terms` | Terms of Service | ❌ | ❌ | ✅ | 7 | +| `help` | Help Center | ❌ | ✅ | ❌ | 8 | +| `size-guide` | Size Guide | ❌ | ❌ | ❌ | - | +| `careers` | Careers | ❌ | ❌ | ❌ | - | +| `cookies` | Cookie Policy | ❌ | ❌ | ✅ | 8 | + +## Security Considerations + +1. **HTML Sanitization**: If using HTML format, sanitize user input to prevent XSS +2. **Authorization**: Stores can only edit their own pages +3. **Published Status**: Only published pages visible to public +4. **Store Isolation**: Stores cannot see/edit other store's content + +## Migration Strategy + +### Initial Setup + +1. **Create Platform Defaults**: +```bash +python scripts/seed/create_default_content_pages.py +``` + +2. **Migrate Existing Static Templates**: +- Convert existing HTML templates to database content +- Preserve existing URLs and SEO + +3. **Update Routes**: +- Add generic content page route handler +- Remove individual route handlers for each page + +## Future Enhancements + +Possible improvements: + +- **Version History**: Track content changes over time +- **Rich Text Editor**: WYSIWYG editor in admin/store panel +- **Image Management**: Upload and insert images +- **Templates**: Pre-built page templates for common pages +- **Localization**: Multi-language content support +- **Scheduled Publishing**: Publish pages at specific times +- **Content Approval**: Admin review before store pages go live + +## API Reference Summary + +### Admin Endpoints + +``` +GET /api/v1/admin/content-pages/ # List all pages +GET /api/v1/admin/content-pages/platform # List platform defaults +POST /api/v1/admin/content-pages/platform # Create platform default +GET /api/v1/admin/content-pages/{id} # Get specific page +PUT /api/v1/admin/content-pages/{id} # Update page +DELETE /api/v1/admin/content-pages/{id} # Delete page +``` + +### Store Endpoints + +``` +GET /api/v1/store/{code}/content-pages/ # List all (store + platform) +GET /api/v1/store/{code}/content-pages/overrides # List store overrides only +GET /api/v1/store/{code}/content-pages/{slug} # Get specific page +POST /api/v1/store/{code}/content-pages/ # Create store override +PUT /api/v1/store/{code}/content-pages/{id} # Update store page +DELETE /api/v1/store/{code}/content-pages/{id} # Delete store page +``` + +### Storefront (Public) Endpoints + +``` +GET /api/v1/storefront/content-pages/navigation # Get navigation links +GET /api/v1/storefront/content-pages/{slug} # Get page content +``` + +## Example: Complete Workflow + +**1. Platform Admin Creates Defaults:** +```bash +# Create "About" page +curl -X POST /api/v1/admin/content-pages/platform \ + -H "Authorization: Bearer " \ + -d '{ + "slug": "about", + "title": "About Our Marketplace", + "content": "

About

Default content...

", + "is_published": true + }' +``` + +**2. All Stores See Platform Default:** +- Store A visits: `store-a.com/about` → Shows platform default +- Store B visits: `store-b.com/about` → Shows platform default + +**3. Store A Creates Override:** +```bash +curl -X POST /api/v1/store/store-a/content-pages/ \ + -H "Authorization: Bearer " \ + -d '{ + "slug": "about", + "title": "About Store A", + "content": "

About Store A

Custom content...

", + "is_published": true + }' +``` + +**4. Now:** +- Store A visits: `store-a.com/about` → Shows Store A custom content +- Store B visits: `store-b.com/about` → Still shows platform default + +**5. Store A Reverts to Default:** +```bash +curl -X DELETE /api/v1/store/store-a/content-pages/15 \ + -H "Authorization: Bearer " +``` + +**6. Result:** +- Store A visits: `store-a.com/about` → Shows platform default again diff --git a/app/modules/cms/docs/data-model.md b/app/modules/cms/docs/data-model.md new file mode 100644 index 00000000..1265530d --- /dev/null +++ b/app/modules/cms/docs/data-model.md @@ -0,0 +1,115 @@ +# CMS Data Model + +Entity relationships and database schema for the CMS module. + +## Entity Relationship Overview + +``` +Platform 1──* ContentPage +Store 1──* ContentPage +Store 1──* MediaFile +Store 1──1 StoreTheme +``` + +## Models + +### ContentPage + +Multi-language content pages with platform/store hierarchy. Pages can be platform marketing pages, store defaults, or store-specific overrides. + +| Field | Type | Constraints | Description | +|-------|------|-------------|-------------| +| `id` | Integer | PK | Primary key | +| `platform_id` | Integer | FK, not null, indexed | Platform this page belongs to | +| `store_id` | Integer | FK, nullable, indexed | Store association (null = platform/default) | +| `is_platform_page` | Boolean | not null, default False | Platform marketing page vs store default | +| `slug` | String(100) | not null, indexed | Page identifier (about, faq, contact, etc.) | +| `title` | String(200) | not null | Page title | +| `content` | Text | not null | HTML or Markdown content | +| `content_format` | String(20) | default "html" | Format: html, markdown | +| `template` | String(50) | default "default" | Template: default, minimal, modern, full | +| `sections` | JSON | nullable | Structured homepage sections with i18n | +| `title_translations` | JSON | nullable | Language-keyed title dict {en, fr, de, lb} | +| `content_translations` | JSON | nullable | Language-keyed content dict {en, fr, de, lb} | +| `meta_description` | String(300) | nullable | SEO meta description | +| `meta_keywords` | String(300) | nullable | SEO keywords | +| `is_published` | Boolean | not null, default False | Publication status | +| `published_at` | DateTime | nullable, tz-aware | Publication timestamp | +| `display_order` | Integer | not null, default 0 | Menu/footer ordering | +| `show_in_footer` | Boolean | not null, default True | Footer visibility | +| `show_in_header` | Boolean | not null, default False | Header navigation | +| `show_in_legal` | Boolean | not null, default False | Legal bar visibility | +| `created_at` | DateTime | tz-aware | Record creation time | +| `updated_at` | DateTime | tz-aware | Record update time | +| `created_by` | Integer | FK, nullable | Creator user ID | +| `updated_by` | Integer | FK, nullable | Updater user ID | + +**Unique Constraint**: `(platform_id, store_id, slug)` +**Composite Indexes**: `(platform_id, store_id, is_published)`, `(platform_id, slug, is_published)`, `(platform_id, is_platform_page)` + +**Page tiers**: platform → store_default (store_id null, not platform) → store_override (store_id set) + +### MediaFile + +Media files (images, videos, documents) managed per-store. + +| Field | Type | Constraints | Description | +|-------|------|-------------|-------------| +| `id` | Integer | PK | Primary key | +| `store_id` | Integer | FK, not null, indexed | Store owner | +| `filename` | String(255) | not null, indexed | UUID-based stored filename | +| `original_filename` | String(255) | nullable | Original upload name | +| `file_path` | String(500) | not null | Relative path from uploads/ | +| `media_type` | String(20) | not null | image, video, document | +| `mime_type` | String(100) | nullable | MIME type | +| `file_size` | Integer | nullable | File size in bytes | +| `width` | Integer | nullable | Image/video width in pixels | +| `height` | Integer | nullable | Image/video height in pixels | +| `thumbnail_path` | String(500) | nullable | Path to thumbnail | +| `alt_text` | String(500) | nullable | Alt text for images | +| `description` | Text | nullable | File description | +| `folder` | String(100) | default "general" | Folder: products, general, etc. | +| `tags` | JSON | nullable | Tags for categorization | +| `extra_metadata` | JSON | nullable | Additional metadata (EXIF, etc.) | +| `is_optimized` | Boolean | default False | Optimization status | +| `optimized_size` | Integer | nullable | Size after optimization | +| `usage_count` | Integer | default 0 | Usage tracking | +| `created_at` | DateTime | tz-aware | Record creation time | +| `updated_at` | DateTime | tz-aware | Record update time | + +**Composite Indexes**: `(store_id, folder)`, `(store_id, media_type)` + +### StoreTheme + +Per-store theme configuration including colors, fonts, layout, and branding. One-to-one with Store. + +| Field | Type | Constraints | Description | +|-------|------|-------------|-------------| +| `id` | Integer | PK | Primary key | +| `store_id` | Integer | FK, unique, not null | One-to-one with store | +| `theme_name` | String(100) | default "default" | Preset: default, modern, classic, minimal, vibrant | +| `is_active` | Boolean | default True | Theme active status | +| `colors` | JSON | default {...} | Color scheme: primary, secondary, accent, background, text, border | +| `font_family_heading` | String(100) | default "Inter, sans-serif" | Heading font | +| `font_family_body` | String(100) | default "Inter, sans-serif" | Body font | +| `logo_url` | String(500) | nullable | Store logo path | +| `logo_dark_url` | String(500) | nullable | Dark mode logo | +| `favicon_url` | String(500) | nullable | Favicon path | +| `banner_url` | String(500) | nullable | Homepage banner | +| `layout_style` | String(50) | default "grid" | Layout: grid, list, masonry | +| `header_style` | String(50) | default "fixed" | Header: fixed, static, transparent | +| `product_card_style` | String(50) | default "modern" | Card: modern, classic, minimal | +| `custom_css` | Text | nullable | Custom CSS overrides | +| `social_links` | JSON | default {} | Social media URLs | +| `meta_title_template` | String(200) | nullable | SEO title template | +| `meta_description` | Text | nullable | SEO meta description | +| `created_at` | DateTime | tz-aware | Record creation time | +| `updated_at` | DateTime | tz-aware | Record update time | + +## Design Patterns + +- **Three-tier content hierarchy**: Platform pages → store defaults → store overrides +- **JSON translations**: Title and content translations stored as JSON dicts with language keys +- **Media organization**: Files organized by store and folder with type classification +- **Theme presets**: Named presets with full customization via JSON color scheme and CSS overrides +- **SEO support**: Meta description, keywords, and title templates on pages and themes diff --git a/app/modules/cms/docs/email-templates-guide.md b/app/modules/cms/docs/email-templates-guide.md new file mode 100644 index 00000000..6bfee6d9 --- /dev/null +++ b/app/modules/cms/docs/email-templates-guide.md @@ -0,0 +1,287 @@ +# Email Templates Guide + +## Overview + +The Orion platform provides a comprehensive email template system that allows: + +- **Platform Administrators**: Manage all email templates across the platform +- **Stores**: Customize customer-facing emails with their own branding + +This guide covers how to use the email template system from both perspectives. + +--- + +## For Stores + +### Accessing Email Templates + +1. Log in to your store dashboard +2. Navigate to **Settings** > **Email Templates** in the sidebar +3. You'll see a list of all customizable email templates + +### Understanding Template Status + +Each template shows its customization status: + +| Status | Description | +|--------|-------------| +| **Platform Default** | Using the standard Orion template | +| **Customized** | You have created a custom version | +| Language badges (green) | Languages where you have customizations | + +### Customizing a Template + +1. Click on any template to open the edit modal +2. Select the language tab you want to customize (EN, FR, DE, LB) +3. Edit the following fields: + - **Subject**: The email subject line + - **HTML Body**: The rich HTML content + - **Plain Text Body**: Fallback for email clients that don't support HTML + +4. Click **Save** to save your customization + +### Template Variables + +Templates use special variables that are automatically replaced with actual values. Common variables include: + +| Variable | Description | +|----------|-------------| +| `{{ customer_name }}` | Customer's first name | +| `{{ order_number }}` | Order reference number | +| `{{ store_name }}` | Your store name | +| `{{ platform_name }}` | Platform name (Orion or your whitelabel name) | + +Each template shows its available variables in the reference panel. + +### Previewing Templates + +Before saving, you can preview your template: + +1. Click **Preview** in the edit modal +2. A preview window shows how the email will look +3. Sample data is used for all variables + +### Testing Templates + +To send a test email: + +1. Click **Send Test Email** in the edit modal +2. Enter your email address +3. Click **Send** +4. Check your inbox to see the actual email + +### Reverting to Platform Default + +If you want to remove your customization and use the platform default: + +1. Open the template edit modal +2. Click **Revert to Default** +3. Confirm the action + +Your customization will be deleted and the platform template will be used. + +### Available Templates for Stores + +| Template | Category | Description | +|----------|----------|-------------| +| Welcome Email | AUTH | Sent when a customer registers | +| Password Reset | AUTH | Password reset link | +| Order Confirmation | ORDERS | Sent after order placement | +| Shipping Notification | ORDERS | Sent when order is shipped | + +**Note:** Billing and subscription emails are platform-only and cannot be customized. + +--- + +## For Platform Administrators + +### Accessing Email Templates + +1. Log in to the admin dashboard +2. Navigate to **System** > **Email Templates** in the sidebar +3. You'll see all platform templates grouped by category + +### Template Categories + +| Category | Description | Store Override | +|----------|-------------|-----------------| +| AUTH | Authentication emails | Allowed | +| ORDERS | Order-related emails | Allowed | +| BILLING | Subscription/payment emails | **Not Allowed** | +| SYSTEM | System notifications | Allowed | +| MARKETING | Promotional emails | Allowed | + +### Editing Platform Templates + +1. Click on any template to open the edit modal +2. Select the language tab (EN, FR, DE, LB) +3. Edit the subject and body content +4. Click **Save** + +**Important:** Changes to platform templates affect: +- All stores who haven't customized the template +- New stores automatically + +### Creating New Templates + +To add a new template: + +1. Use the database seed script or migration +2. Define the template code, category, and languages +3. Set `is_platform_only` if stores shouldn't override it + +### Viewing Email Logs + +To see email delivery history: + +1. Open a template +2. Click **View Logs** +3. See recent emails sent using this template + +Logs show: +- Recipient email +- Send date/time +- Delivery status +- Store (if applicable) + +### Template Best Practices + +1. **Use all 4 languages**: Provide content in EN, FR, DE, and LB +2. **Test before publishing**: Always send test emails +3. **Include plain text**: Not all email clients support HTML +4. **Use consistent branding**: Follow Orion brand guidelines +5. **Keep subjects short**: Under 60 characters for mobile + +--- + +## Language Resolution + +When sending an email, the system determines the language in this order: + +1. **Customer's preferred language** (if set in their profile) +2. **Store's storefront language** (if customer doesn't have preference) +3. **Platform default** (French - "fr") + +### Template Resolution for Stores + +1. System checks if store has a custom override +2. If yes, uses store's template +3. If no, falls back to platform template +4. If requested language unavailable, falls back to English + +--- + +## Branding + +### Standard Stores + +Standard stores' emails include Orion branding: +- Orion logo in header +- "Powered by Orion" footer + +### Whitelabel Stores + +Enterprise-tier stores with whitelabel enabled: +- No Orion branding +- Store's logo in header +- Custom footer (if configured) + +--- + +## Email Template Variables Reference + +### Authentication Templates + +#### signup_welcome +``` +{{ first_name }} - Customer's first name +{{ merchant_name }} - Store merchant name +{{ email }} - Customer's email +{{ login_url }} - Link to login page +{{ trial_days }} - Trial period length +{{ tier_name }} - Subscription tier +``` + +#### password_reset +``` +{{ customer_name }} - Customer's name +{{ reset_link }} - Password reset URL +{{ expiry_hours }} - Link expiration time +``` + +### Order Templates + +#### order_confirmation +``` +{{ customer_name }} - Customer's name +{{ order_number }} - Order reference +{{ order_total }} - Order total amount +{{ order_items_count }} - Number of items +{{ order_date }} - Order date +{{ shipping_address }} - Delivery address +``` + +### Common Variables (All Templates) + +``` +{{ platform_name }} - "Orion" or whitelabel name +{{ platform_logo_url }} - Platform logo URL +{{ support_email }} - Support email address +{{ store_name }} - Store's business name +{{ store_logo_url }} - Store's logo URL +``` + +--- + +## Troubleshooting + +### Email Not Received + +1. Check spam/junk folder +2. Verify email address is correct +3. Check email logs in admin dashboard +4. Verify SMTP configuration + +### Template Not Applying + +1. Clear browser cache +2. Verify the correct language is selected +3. Check if store override exists +4. Verify template is not platform-only + +### Variables Not Replaced + +1. Check variable spelling (case-sensitive) +2. Ensure variable is available for this template +3. Wrap variables in `{{ }}` syntax +4. Check for typos in variable names + +--- + +## API Reference + +For developers integrating with the email system: + +### Sending a Template Email + +```python +from app.services.email_service import EmailService + +email_service = EmailService(db) +email_service.send_template( + template_code="order_confirmation", + to_email="customer@example.com", + to_name="John Doe", + language="fr", + variables={ + "customer_name": "John", + "order_number": "ORD-12345", + "order_total": "99.99", + }, + store_id=store.id, + related_type="order", + related_id=order.id, +) +``` + +See [Email Templates Architecture](../cms/email-templates.md) for full technical documentation. diff --git a/app/modules/cms/docs/email-templates.md b/app/modules/cms/docs/email-templates.md new file mode 100644 index 00000000..46d12154 --- /dev/null +++ b/app/modules/cms/docs/email-templates.md @@ -0,0 +1,458 @@ +# Email Template System + +## Overview + +The email template system provides comprehensive email customization for the Orion platform with the following features: + +- **Platform-level templates** with store overrides +- **Orion branding** by default (removed for Enterprise whitelabel tier) +- **Platform-only templates** that cannot be overridden (billing, subscriptions) +- **Admin UI** for editing platform templates +- **Store UI** for customizing customer-facing emails +- **4-language support** (en, fr, de, lb) +- **Smart language resolution** (customer → store → platform default) + +--- + +## Architecture + +### Database Models + +#### EmailTemplate (Platform Templates) +**File:** `models/database/email.py` + +| Column | Type | Description | +|--------|------|-------------| +| `id` | Integer | Primary key | +| `code` | String(100) | Unique template identifier | +| `language` | String(5) | Language code (en, fr, de, lb) | +| `name` | String(255) | Human-readable name | +| `description` | Text | Template description | +| `category` | Enum | AUTH, ORDERS, BILLING, SYSTEM, MARKETING | +| `subject` | String(500) | Email subject line (Jinja2) | +| `body_html` | Text | HTML body (Jinja2) | +| `body_text` | Text | Plain text body (Jinja2) | +| `variables` | JSON | List of available variables | +| `is_platform_only` | Boolean | Cannot be overridden by stores | +| `required_variables` | Text | Comma-separated required variables | + +**Key Methods:** +- `get_by_code_and_language(db, code, language)` - Get specific template +- `get_overridable_templates(db)` - Get templates stores can customize + +#### StoreEmailTemplate (Store Overrides) +**File:** `models/database/store_email_template.py` + +| Column | Type | Description | +|--------|------|-------------| +| `id` | Integer | Primary key | +| `store_id` | Integer | FK to stores.id | +| `template_code` | String(100) | References EmailTemplate.code | +| `language` | String(5) | Language code | +| `name` | String(255) | Custom name (optional) | +| `subject` | String(500) | Custom subject | +| `body_html` | Text | Custom HTML body | +| `body_text` | Text | Custom plain text body | +| `created_at` | DateTime | Creation timestamp | +| `updated_at` | DateTime | Last update timestamp | + +**Key Methods:** +- `get_override(db, store_id, code, language)` - Get store override +- `create_or_update(db, store_id, code, language, ...)` - Upsert override +- `delete_override(db, store_id, code, language)` - Revert to platform default +- `get_all_overrides_for_store(db, store_id)` - List all store overrides + +### Unique Constraint +```sql +UNIQUE (store_id, template_code, language) +``` + +--- + +## Email Template Service + +**File:** `app/services/email_template_service.py` + +The `EmailTemplateService` encapsulates all email template business logic, keeping API endpoints clean and focused on request/response handling. + +### Admin Methods + +| Method | Description | +|--------|-------------| +| `list_platform_templates()` | List all platform templates grouped by code | +| `get_template_categories()` | Get list of template categories | +| `get_platform_template(code)` | Get template with all language versions | +| `update_platform_template(code, language, data)` | Update platform template content | +| `preview_template(code, language, variables)` | Generate preview with sample data | +| `get_template_logs(code, limit)` | Get email logs for template | + +### Store Methods + +| Method | Description | +|--------|-------------| +| `list_overridable_templates(store_id)` | List templates store can customize | +| `get_store_template(store_id, code, language)` | Get template (override or platform default) | +| `create_or_update_store_override(store_id, code, language, data)` | Save store customization | +| `delete_store_override(store_id, code, language)` | Revert to platform default | +| `preview_store_template(store_id, code, language, variables)` | Preview with store branding | + +### Usage Example + +```python +from app.services.email_template_service import EmailTemplateService + +service = EmailTemplateService(db) + +# List templates for admin +templates = service.list_platform_templates() + +# Get store's view of a template +template_data = service.get_store_template(store_id, "order_confirmation", "fr") + +# Create store override +service.create_or_update_store_override( + store_id=store.id, + code="order_confirmation", + language="fr", + subject="Votre commande {{ order_number }}", + body_html="...", + body_text="Plain text...", +) +``` + +--- + +## Email Service + +**File:** `app/services/email_service.py` + +### Language Resolution + +Priority order for determining email language: + +1. **Customer preferred language** (if customer exists) +2. **Store storefront language** (store.storefront_language) +3. **Platform default** (`en`) + +```python +def resolve_language( + self, + customer_id: int | None, + store_id: int | None, + explicit_language: str | None = None +) -> str +``` + +### Template Resolution + +```python +def resolve_template( + self, + template_code: str, + language: str, + store_id: int | None = None +) -> ResolvedTemplate +``` + +Resolution order: +1. If `store_id` provided and template **not** platform-only: + - Look for `StoreEmailTemplate` override + - Fall back to platform `EmailTemplate` +2. If no store or platform-only: + - Use platform `EmailTemplate` +3. Language fallback: `requested_language` → `en` + +### Branding Resolution + +```python +def get_branding(self, store_id: int | None) -> BrandingContext +``` + +| Scenario | Platform Name | Platform Logo | +|----------|--------------|---------------| +| No store | Orion | Orion logo | +| Standard store | Orion | Orion logo | +| Whitelabel store | Store name | Store logo | + +Whitelabel is determined by the `white_label` feature flag on the store. + +--- + +## API Endpoints + +### Admin API + +**File:** `app/api/v1/admin/email_templates.py` + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/admin/email-templates` | List all platform templates | +| GET | `/api/v1/admin/email-templates/categories` | Get template categories | +| GET | `/api/v1/admin/email-templates/{code}` | Get template (all languages) | +| GET | `/api/v1/admin/email-templates/{code}/{language}` | Get specific language version | +| PUT | `/api/v1/admin/email-templates/{code}/{language}` | Update template | +| POST | `/api/v1/admin/email-templates/{code}/preview` | Preview with sample data | +| POST | `/api/v1/admin/email-templates/{code}/test` | Send test email | +| GET | `/api/v1/admin/email-templates/{code}/logs` | View email logs for template | + +### Store API + +**File:** `app/api/v1/store/email_templates.py` + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/store/email-templates` | List overridable templates | +| GET | `/api/v1/store/email-templates/{code}` | Get template with override status | +| GET | `/api/v1/store/email-templates/{code}/{language}` | Get specific language (override or default) | +| PUT | `/api/v1/store/email-templates/{code}/{language}` | Create/update override | +| DELETE | `/api/v1/store/email-templates/{code}/{language}` | Reset to platform default | +| POST | `/api/v1/store/email-templates/{code}/preview` | Preview with store branding | +| POST | `/api/v1/store/email-templates/{code}/test` | Send test email | + +--- + +## User Interface + +### Admin UI + +**Page:** `/admin/email-templates` +**Template:** `app/templates/admin/email-templates.html` +**JavaScript:** `static/admin/js/email-templates.js` + +Features: +- Template list with category filtering +- Edit modal with language tabs (en, fr, de, lb) +- Platform-only indicator badge +- Variable reference panel +- HTML preview in iframe +- Send test email functionality + +### Store UI + +**Page:** `/store/{store_code}/email-templates` +**Template:** `app/templates/store/email-templates.html` +**JavaScript:** `static/store/js/email-templates.js` + +Features: +- List of overridable templates with customization status +- Language override badges (green = customized) +- Edit modal with: + - Language tabs + - Source indicator (store override vs platform default) + - Platform template reference + - Revert to default button +- Preview and test email functionality + +--- + +## Template Categories + +| Category | Description | Platform-Only | +|----------|-------------|---------------| +| AUTH | Authentication emails (welcome, password reset) | No | +| ORDERS | Order-related emails (confirmation, shipped) | No | +| BILLING | Subscription/payment emails | Yes | +| SYSTEM | System emails (team invites, alerts) | No | +| MARKETING | Marketing/promotional emails | No | + +--- + +## Available Templates + +### Customer-Facing (Overridable) + +| Code | Category | Languages | Description | +|------|----------|-----------|-------------| +| `signup_welcome` | AUTH | en, fr, de, lb | Welcome email after store signup | +| `order_confirmation` | ORDERS | en, fr, de, lb | Order confirmation to customer | +| `password_reset` | AUTH | en, fr, de, lb | Password reset link | +| `team_invite` | SYSTEM | en | Team member invitation | + +### Platform-Only (Not Overridable) + +| Code | Category | Languages | Description | +|------|----------|-----------|-------------| +| `subscription_welcome` | BILLING | en | Subscription confirmation | +| `payment_failed` | BILLING | en | Failed payment notification | +| `subscription_cancelled` | BILLING | en | Cancellation confirmation | +| `trial_ending` | BILLING | en | Trial ending reminder | + +--- + +## Template Variables + +### Common Variables (Injected Automatically) + +| Variable | Description | +|----------|-------------| +| `platform_name` | "Orion" or store name (whitelabel) | +| `platform_logo_url` | Platform logo URL | +| `support_email` | Support email address | +| `store_name` | Store business name | +| `store_logo_url` | Store logo URL | + +### Template-Specific Variables + +#### signup_welcome +- `first_name`, `merchant_name`, `email`, `store_code` +- `login_url`, `trial_days`, `tier_name` + +#### order_confirmation +- `customer_name`, `order_number`, `order_total` +- `order_items_count`, `order_date`, `shipping_address` + +#### password_reset +- `customer_name`, `reset_link`, `expiry_hours` + +#### team_invite +- `invitee_name`, `inviter_name`, `store_name` +- `role`, `accept_url`, `expires_in_days` + +--- + +## Migration + +**File:** `alembic/versions/u9c0d1e2f3g4_add_store_email_templates.py` + +Run migration: +```bash +alembic upgrade head +``` + +The migration: +1. Adds `is_platform_only` and `required_variables` columns to `email_templates` +2. Creates `store_email_templates` table +3. Adds unique constraint on `(store_id, template_code, language)` +4. Creates indexes for performance + +--- + +## Seeding Templates + +**File:** `scripts/seed/seed_email_templates.py` + +Run seed script: +```bash +python scripts/seed/seed_email_templates.py +``` + +The script: +- Creates/updates all platform templates +- Supports all 4 languages for customer-facing templates +- Sets `is_platform_only` flag for billing templates + +--- + +## Security Considerations + +1. **XSS Prevention**: HTML templates are rendered server-side with Jinja2 escaping +2. **Access Control**: Stores can only view/edit their own overrides +3. **Platform-only Protection**: API enforces `is_platform_only` flag +4. **Template Validation**: Jinja2 syntax validated before save +5. **Rate Limiting**: Test email sending subject to rate limits +6. **Token Hashing**: Password reset tokens stored as SHA256 hashes + +--- + +## Usage Examples + +### Sending a Template Email + +```python +from app.services.email_service import EmailService + +email_svc = EmailService(db) +email_log = email_svc.send_template( + template_code="order_confirmation", + to_email="customer@example.com", + variables={ + "customer_name": "John Doe", + "order_number": "ORD-12345", + "order_total": "€99.99", + "order_items_count": "3", + "order_date": "2024-01-15", + "shipping_address": "123 Main St, Luxembourg" + }, + store_id=store.id, # Optional: enables store override lookup + customer_id=customer.id, # Optional: for language resolution + language="fr" # Optional: explicit language override +) +``` + +### Creating a Store Override + +```python +from models.database.store_email_template import StoreEmailTemplate + +override = StoreEmailTemplate.create_or_update( + db=db, + store_id=store.id, + template_code="order_confirmation", + language="fr", + subject="Confirmation de votre commande {{ order_number }}", + body_html="...", + body_text="Plain text version..." +) +db.commit() +``` + +### Reverting to Platform Default + +```python +StoreEmailTemplate.delete_override( + db=db, + store_id=store.id, + template_code="order_confirmation", + language="fr" +) +db.commit() +``` + +--- + +## File Structure + +``` +├── alembic/versions/ +│ └── u9c0d1e2f3g4_add_store_email_templates.py +├── app/ +│ ├── api/v1/ +│ │ ├── admin/ +│ │ │ └── email_templates.py +│ │ └── store/ +│ │ └── email_templates.py +│ ├── routes/ +│ │ ├── admin_pages.py (route added) +│ │ └── store_pages.py (route added) +│ ├── services/ +│ │ ├── email_service.py (enhanced) +│ │ └── email_template_service.py (new - business logic) +│ └── templates/ +│ ├── admin/ +│ │ ├── email-templates.html +│ │ └── partials/sidebar.html (link added) +│ └── store/ +│ ├── email-templates.html +│ └── partials/sidebar.html (link added) +├── models/ +│ ├── database/ +│ │ ├── email.py (enhanced) +│ │ └── store_email_template.py +│ └── schema/ +│ └── email.py +├── scripts/ +│ └── seed_email_templates.py (enhanced) +└── static/ + ├── admin/js/ + │ └── email-templates.js + └── store/js/ + └── email-templates.js +``` + +--- + +## Related Documentation + +- [Email Templates User Guide](email-templates-guide.md) - How to use the email template system +- [Password Reset Implementation](../../implementation/password-reset-implementation.md) - Password reset feature using email templates +- [Architecture Fixes (January 2026)](../../development/architecture-fixes-2026-01.md) - Architecture validation fixes diff --git a/app/modules/cms/docs/implementation.md b/app/modules/cms/docs/implementation.md new file mode 100644 index 00000000..5791e37c --- /dev/null +++ b/app/modules/cms/docs/implementation.md @@ -0,0 +1,414 @@ +# CMS Implementation Guide + +## Quick Start + +This guide shows you how to implement the Content Management System for static pages. + +## What Was Implemented + +✅ **Database Model**: `models/database/content_page.py` +✅ **Service Layer**: `app/services/content_page_service.py` +✅ **Admin API**: `app/api/v1/admin/content_pages.py` +✅ **Store API**: `app/api/v1/store/content_pages.py` +✅ **Storefront API**: `app/api/v1/storefront/content_pages.py` +✅ **Documentation**: Full CMS documentation in `docs/features/content-management-system.md` + +## Next Steps to Activate + +### 1. Create Database Migration + +```bash +# Create Alembic migration +alembic revision --autogenerate -m "Add content_pages table" + +# Review the generated migration in alembic/versions/ + +# Run migration +alembic upgrade head +``` + +### 2. Add Relationship to Store Model + +Edit `models/database/store.py` and add this relationship: + +```python +# Add this import +from sqlalchemy.orm import relationship + +# Add this relationship to Store class +content_pages = relationship("ContentPage", back_populates="store", cascade="all, delete-orphan") +``` + +### 3. Register API Routers + +Edit the appropriate router files to include the new endpoints: + +**Admin Router** (`app/api/v1/admin/__init__.py`): +```python +from app.api.v1.admin import content_pages + +api_router.include_router( + content_pages.router, + prefix="/content-pages", + tags=["admin-content-pages"] +) +``` + +**Store Router** (`app/api/v1/store/__init__.py`): +```python +from app.api.v1.store import content_pages + +api_router.include_router( + content_pages.router, + prefix="/{store_code}/content-pages", + tags=["store-content-pages"] +) +``` + +**Storefront Router** (`app/api/v1/storefront/__init__.py` or create if doesn't exist): +```python +from app.api.v1.storefront import content_pages + +api_router.include_router( + content_pages.router, + prefix="/content-pages", + tags=["storefront-content-pages"] +) +``` + +### 4. Update Storefront Routes to Use CMS + +Edit `app/routes/storefront_pages.py` to add a generic content page handler: + +```python +from app.services.content_page_service import content_page_service + +@router.get("/{slug}", response_class=HTMLResponse, include_in_schema=False) +async def generic_content_page( + slug: str, + request: Request, + db: Session = Depends(get_db) +): + """ + Generic content page handler. + Handles: /about, /faq, /contact, /shipping, /returns, /privacy, /terms, etc. + """ + store = getattr(request.state, 'store', None) + store_id = store.id if store else None + + page = content_page_service.get_page_for_store( + db, + slug=slug, + store_id=store_id, + include_unpublished=False + ) + + if not page: + raise HTTPException(status_code=404, detail=f"Page not found: {slug}") + + return templates.TemplateResponse( + "storefront/content-page.html", + get_storefront_context(request, page=page) + ) +``` + +### 5. Create Generic Content Page Template + +Create `app/templates/storefront/content-page.html`: + +```jinja2 +{# app/templates/storefront/content-page.html #} +{% extends "storefront/base.html" %} + +{% block title %}{{ page.title }}{% endblock %} + +{% block meta_description %} + {{ page.meta_description or page.title }} +{% endblock %} + +{% block content %} +
+ + {# Breadcrumbs #} + + + {# Page Title #} +

+ {{ page.title }} +

+ + {# Content #} +
+ {{ page.content | safe }} +
+ + {# Last updated #} + {% if page.updated_at %} +
+ Last updated: {{ page.updated_at.strftime('%B %d, %Y') }} +
+ {% endif %} + +
+{% endblock %} +``` + +### 6. Update Footer to Load Navigation Dynamically + +Edit `app/templates/storefront/base.html` to load navigation from database. + +First, update the context helper to include footer pages: + +```python +# app/routes/storefront_pages.py + +def get_storefront_context(request: Request, **extra_context) -> dict: + # ... existing code ... + + # Load footer navigation pages + db = next(get_db()) + try: + footer_pages = content_page_service.list_pages_for_store( + db, + store_id=store.id if store else None, + include_unpublished=False, + footer_only=True + ) + finally: + db.close() + + context = { + "request": request, + "store": store, + "theme": theme, + "clean_path": clean_path, + "access_method": access_method, + "base_url": base_url, + "footer_pages": footer_pages, # Add this + **extra_context + } + + return context +``` + +Then update the footer template: + +```jinja2 +{# app/templates/storefront/base.html - Footer section #} + +
+

Quick Links

+ +
+``` + +### 7. Create Default Platform Pages (Script) + +Create `scripts/seed/create_default_content_pages.py`: + +```python +#!/usr/bin/env python3 +"""Create default platform content pages.""" + +from sqlalchemy.orm import Session +from app.core.database import SessionLocal +from app.services.content_page_service import content_page_service + +def create_defaults(): + db: Session = SessionLocal() + + try: + # About Us + content_page_service.create_page( + db, + slug="about", + title="About Us", + content=""" +

Welcome to Our Marketplace

+

We connect quality stores with customers worldwide.

+

Our mission is to provide a seamless shopping experience...

+ """, + is_published=True, + show_in_footer=True, + display_order=1 + ) + + # Shipping Information + content_page_service.create_page( + db, + slug="shipping", + title="Shipping Information", + content=""" +

Shipping Policy

+

We offer fast and reliable shipping...

+ """, + is_published=True, + show_in_footer=True, + display_order=2 + ) + + # Returns + content_page_service.create_page( + db, + slug="returns", + title="Returns & Refunds", + content=""" +

Return Policy

+

30-day return policy on all items...

+ """, + is_published=True, + show_in_footer=True, + display_order=3 + ) + + # Privacy Policy + content_page_service.create_page( + db, + slug="privacy", + title="Privacy Policy", + content=""" +

Privacy Policy

+

Your privacy is important to us...

+ """, + is_published=True, + show_in_footer=True, + display_order=4 + ) + + # Terms of Service + content_page_service.create_page( + db, + slug="terms", + title="Terms of Service", + content=""" +

Terms of Service

+

By using our platform, you agree to...

+ """, + is_published=True, + show_in_footer=True, + display_order=5 + ) + + # Contact + content_page_service.create_page( + db, + slug="contact", + title="Contact Us", + content=""" +

Get in Touch

+

Have questions? We'd love to hear from you!

+

Email: support@example.com

+ """, + is_published=True, + show_in_footer=True, + display_order=6 + ) + + # FAQ + content_page_service.create_page( + db, + slug="faq", + title="Frequently Asked Questions", + content=""" +

FAQ

+

How do I place an order?

+

Simply browse our products...

+ """, + is_published=True, + show_in_footer=True, + display_order=7 + ) + + print("✅ Created default content pages successfully") + + except Exception as e: + print(f"❌ Error: {e}") + db.rollback() + finally: + db.close() + + +if __name__ == "__main__": + create_defaults() +``` + +Run it: +```bash +python scripts/seed/create_default_content_pages.py +``` + +## Testing + +### 1. Test Platform Defaults + +```bash +# Create platform default +curl -X POST http://localhost:8000/api/v1/admin/content-pages/platform \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "slug": "about", + "title": "About Our Marketplace", + "content": "

About

Platform default content

", + "is_published": true, + "show_in_footer": true + }' + +# View in storefront +curl http://localhost:8000/store/orion/about +``` + +### 2. Test Store Override + +```bash +# Create store override +curl -X POST http://localhost:8000/api/v1/store/orion/content-pages/ \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "slug": "about", + "title": "About Orion", + "content": "

About Orion

Custom store content

", + "is_published": true + }' + +# View in storefront (should show store content) +curl http://localhost:8000/store/orion/about +``` + +### 3. Test Fallback + +```bash +# Delete store override +curl -X DELETE http://localhost:8000/api/v1/store/orion/content-pages/{id} \ + -H "Authorization: Bearer " + +# View in storefront (should fall back to platform default) +curl http://localhost:8000/store/orion/about +``` + +## Summary + +You now have a complete CMS system that allows: + +1. **Platform admins** to create default content for all stores +2. **Stores** to override specific pages with custom content +3. **Automatic fallback** to platform defaults when store hasn't customized +4. **Dynamic navigation** loading from database +5. **SEO optimization** with meta tags +6. **Draft/Published workflow** for content management + +All pages are accessible via their slug: `/about`, `/faq`, `/contact`, etc. with proper store context and routing support! diff --git a/app/modules/cms/docs/index.md b/app/modules/cms/docs/index.md new file mode 100644 index 00000000..f09a4bb5 --- /dev/null +++ b/app/modules/cms/docs/index.md @@ -0,0 +1,61 @@ +# Content Management + +Content pages, media library, and store themes. + +## Overview + +| Aspect | Detail | +|--------|--------| +| Code | `cms` | +| Classification | Core | +| Dependencies | None | +| Status | Active | + +## Features + +- `cms_basic` — Basic content page management +- `cms_custom_pages` — Custom page creation +- `cms_unlimited_pages` — Unlimited pages (tier-gated) +- `cms_templates` — Page templates +- `cms_seo` — SEO metadata management +- `media_library` — Media file upload and management + +## Permissions + +| Permission | Description | +|------------|-------------| +| `cms.view_pages` | View content pages | +| `cms.manage_pages` | Create/edit/delete pages | +| `cms.view_media` | View media library | +| `cms.manage_media` | Upload/delete media files | +| `cms.manage_themes` | Manage store themes | + +## Data Model + +See [Data Model](data-model.md) for full entity relationships and schema. + +- **ContentPage** — Multi-language content pages with platform/store hierarchy +- **MediaFile** — Media files with optimization and folder organization +- **StoreTheme** — Theme presets, colors, fonts, and branding + +## API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `*` | `/api/v1/admin/content-pages/*` | Content page CRUD | +| `*` | `/api/v1/admin/media/*` | Media library management | +| `*` | `/api/v1/admin/images/*` | Image upload/management | +| `*` | `/api/v1/admin/store-themes/*` | Theme management | + +## Configuration + +No module-specific configuration. + +## Additional Documentation + +- [Data Model](data-model.md) — Entity relationships and database schema +- [Architecture](architecture.md) — CMS architecture and database schema +- [Implementation](implementation.md) — Implementation checklist and status +- [Email Templates](email-templates.md) — Email template system architecture +- [Email Templates Guide](email-templates-guide.md) — Template customization guide +- [Media Library](media-library.md) — Media library usage guide diff --git a/app/modules/cms/docs/media-library.md b/app/modules/cms/docs/media-library.md new file mode 100644 index 00000000..48d3d06e --- /dev/null +++ b/app/modules/cms/docs/media-library.md @@ -0,0 +1,182 @@ +# Media Library + +The media library provides centralized management of uploaded files (images, documents) for stores. Each store has their own isolated media storage. + +## Overview + +- **Storage Location**: `uploads/stores/{store_id}/{folder}/` +- **Supported Types**: Images (JPG, PNG, GIF, WebP), Documents (PDF) +- **Max File Size**: 10MB per file +- **Automatic Thumbnails**: Generated for images (200x200px) + +## API Endpoints + +### Admin Media Management + +Admins can manage media for any store: + +``` +GET /api/v1/admin/media/stores/{store_id} # List store's media +POST /api/v1/admin/media/stores/{store_id}/upload # Upload file +GET /api/v1/admin/media/stores/{store_id}/{id} # Get media details +DELETE /api/v1/admin/media/stores/{store_id}/{id} # Delete media +``` + +### Query Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `skip` | int | Pagination offset (default: 0) | +| `limit` | int | Items per page (default: 100, max: 1000) | +| `media_type` | string | Filter by type: `image`, `video`, `document` | +| `folder` | string | Filter by folder: `products`, `general`, etc. | +| `search` | string | Search by filename | + +### Upload Response + +```json +{ + "success": true, + "message": "File uploaded successfully", + "media": { + "id": 1, + "filename": "product-image.jpg", + "file_url": "/uploads/stores/1/products/abc123.jpg", + "url": "/uploads/stores/1/products/abc123.jpg", + "thumbnail_url": "/uploads/stores/1/thumbnails/thumb_abc123.jpg", + "media_type": "image", + "file_size": 245760, + "width": 1200, + "height": 800 + } +} +``` + +## Media Picker Component + +A reusable Alpine.js component for selecting images from the media library. + +### Usage in Templates + +```jinja2 +{% from 'shared/macros/modals.html' import media_picker_modal %} + +{# Single image selection #} +{{ media_picker_modal( + id='media-picker-main', + show_var='showMediaPicker', + store_id_var='storeId', + title='Select Image' +) }} + +{# Multiple image selection #} +{{ media_picker_modal( + id='media-picker-additional', + show_var='showMediaPickerAdditional', + store_id_var='storeId', + multi_select=true, + title='Select Additional Images' +) }} +``` + +### JavaScript Integration + +Include the media picker mixin in your Alpine.js component: + +```javascript +function myComponent() { + return { + ...data(), + + // Include media picker functionality + ...mediaPickerMixin(() => this.storeId, false), + + storeId: null, + + // Override to handle selected image + setMainImage(media) { + this.form.image_url = media.url; + }, + + // Override for multiple images + addAdditionalImages(mediaList) { + const urls = mediaList.map(m => m.url); + this.form.additional_images.push(...urls); + } + }; +} +``` + +### Media Picker Mixin API + +| Property/Method | Description | +|-----------------|-------------| +| `showMediaPicker` | Boolean to show/hide main image picker modal | +| `showMediaPickerAdditional` | Boolean to show/hide additional images picker | +| `mediaPickerState` | Object containing loading, media array, selected items | +| `openMediaPickerMain()` | Open picker for main image | +| `openMediaPickerAdditional()` | Open picker for additional images | +| `loadMediaLibrary()` | Fetch media from API | +| `uploadMediaFile(event)` | Handle file upload | +| `toggleMediaSelection(media)` | Select/deselect a media item | +| `confirmMediaSelection()` | Confirm selection and call callbacks | +| `setMainImage(media)` | Override to handle main image selection | +| `addAdditionalImages(mediaList)` | Override to handle multiple selections | + +## File Storage + +### Directory Structure + +``` +uploads/ +└── stores/ + └── {store_id}/ + ├── products/ # Product images + ├── general/ # General uploads + └── thumbnails/ # Auto-generated thumbnails +``` + +### URL Paths + +Files are served from `/uploads/` path: +- Full image: `/uploads/stores/1/products/image.jpg` +- Thumbnail: `/uploads/stores/1/thumbnails/thumb_image.jpg` + +## Database Model + +```python +class MediaFile(Base): + id: int + store_id: int + filename: str # Stored filename (UUID-based) + original_filename: str # Original upload name + file_path: str # Relative path from uploads/ + thumbnail_path: str # Thumbnail relative path + media_type: str # image, video, document + mime_type: str # image/jpeg, etc. + file_size: int # Bytes + width: int # Image width + height: int # Image height + folder: str # products, general, etc. +``` + +## Product Images + +Products support both a main image and additional images: + +```python +class Product(Base): + primary_image_url: str # Main product image + additional_images: list[str] # Array of additional image URLs +``` + +### In Product Forms + +The product create/edit forms include: +1. **Main Image**: Single image with preview and media picker +2. **Additional Images**: Grid of images with add/remove functionality + +Both support: +- Browsing the store's media library +- Uploading new images directly +- Entering external URLs manually diff --git a/app/modules/contracts/docs/index.md b/app/modules/contracts/docs/index.md new file mode 100644 index 00000000..0ab222ef --- /dev/null +++ b/app/modules/contracts/docs/index.md @@ -0,0 +1,33 @@ +# Module Contracts + +Cross-module contracts using Protocol pattern for type-safe inter-module communication. + +## Overview + +| Aspect | Detail | +|--------|--------| +| Code | `contracts` | +| Classification | Core | +| Dependencies | None | +| Status | Active | + +## Features + +- `service_protocols` — Protocol definitions for cross-module service interfaces +- `cross_module_interfaces` — Type-safe inter-module communication contracts + +## Permissions + +No permissions defined. + +## Data Model + +No database models. + +## API Endpoints + +No API endpoints. + +## Configuration + +No module-specific configuration. diff --git a/app/modules/core/docs/index.md b/app/modules/core/docs/index.md new file mode 100644 index 00000000..abb9abc7 --- /dev/null +++ b/app/modules/core/docs/index.md @@ -0,0 +1,44 @@ +# Core Platform + +Dashboard, settings, and profile management. Required for basic operation. + +## Overview + +| Aspect | Detail | +|--------|--------| +| Code | `core` | +| Classification | Core | +| Dependencies | None | +| Status | Active | + +## Features + +- `dashboard` — Main admin dashboard +- `settings` — Platform and store settings management +- `profile` — User profile management + +## Permissions + +| Permission | Description | +|------------|-------------| +| `dashboard.view` | View the admin dashboard | +| `settings.view` | View settings | +| `settings.edit` | Edit settings | +| `settings.theme` | Manage theme settings | +| `settings.domains` | Manage domains (owner only) | + +## Data Model + +- **AdminMenuConfig** — Stores menu configuration for admin sidebar + +## API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/v1/admin/dashboard` | Dashboard data | +| `GET/PATCH` | `/api/v1/admin/settings` | Settings CRUD | +| `GET/PATCH` | `/api/v1/admin/menu-config` | Menu configuration | + +## Configuration + +No module-specific configuration. diff --git a/app/modules/customers/docs/index.md b/app/modules/customers/docs/index.md new file mode 100644 index 00000000..ca6208d6 --- /dev/null +++ b/app/modules/customers/docs/index.md @@ -0,0 +1,47 @@ +# Customer Management + +Customer database, profiles, addresses, and segmentation. + +## Overview + +| Aspect | Detail | +|--------|--------| +| Code | `customers` | +| Classification | Core | +| Dependencies | None | +| Status | Active | + +## Features + +- `customer_view` — View customer records +- `customer_export` — Export customer data +- `customer_profiles` — Customer profile management +- `customer_segmentation` — Customer segmentation and filtering +- `customer_addresses` — Address book management +- `customer_authentication` — Storefront customer login/registration + +## Permissions + +| Permission | Description | +|------------|-------------| +| `customers.view` | View customer records | +| `customers.edit` | Edit customer data | +| `customers.delete` | Delete customers | +| `customers.export` | Export customer data | + +## Data Model + +- **Customer** — Customer records with store-scoped profiles +- **PasswordResetToken** — Password reset tokens for customer accounts + +## API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `*` | `/api/v1/admin/customers/*` | Admin customer management | +| `*` | `/api/v1/store/customers/*` | Store-level customer management | +| `*` | `/api/v1/storefront/customers/*` | Customer self-service (profile, addresses) | + +## Configuration + +No module-specific configuration. diff --git a/app/modules/dev_tools/docs/index.md b/app/modules/dev_tools/docs/index.md new file mode 100644 index 00000000..9f513af6 --- /dev/null +++ b/app/modules/dev_tools/docs/index.md @@ -0,0 +1,42 @@ +# Developer Tools + +Internal development tools including code quality scanning, test execution, component library, and icon browser. + +## Overview + +| Aspect | Detail | +|--------|--------| +| Code | `dev-tools` | +| Classification | Internal | +| Dependencies | None | +| Status | Active | + +## Features + +- `component_library` — UI component browser and documentation +- `icon_browser` — Icon set browser +- `code_quality` — Code quality scanning and reporting +- `architecture_validation` — Module architecture validation +- `security_validation` — Security rule checking +- `performance_validation` — Performance rule checking +- `test_runner` — Test execution interface +- `violation_management` — Architecture violation tracking + +## Permissions + +No permissions defined (internal module, admin-only access). + +## Data Model + +- **ArchitectureScan** — Code quality scan results +- **TestRun** — Test execution records + +## API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `*` | `/api/v1/admin/dev-tools/*` | Dev tools API | + +## Configuration + +No module-specific configuration. diff --git a/app/modules/hosting/docs/index.md b/app/modules/hosting/docs/index.md new file mode 100644 index 00000000..e7ea8f51 --- /dev/null +++ b/app/modules/hosting/docs/index.md @@ -0,0 +1,49 @@ +# Hosting + +Web hosting, domains, email, and website building for Luxembourg businesses. + +## Overview + +| Aspect | Detail | +|--------|--------| +| Code | `hosting` | +| Classification | Optional | +| Dependencies | `prospecting` | +| Status | Active | + +## Features + +- `hosting` — Web hosting management +- `domains` — Domain registration and management +- `email` — Email hosting +- `ssl` — SSL certificate management +- `poc_sites` — Proof-of-concept site builder + +## Permissions + +| Permission | Description | +|------------|-------------| +| `hosting.view` | View hosting data | +| `hosting.manage` | Manage hosting services | + +## Data Model + +- **HostedSite** — Hosted website records +- **ClientService** — Client service subscriptions + +## API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `*` | `/api/v1/admin/hosting/*` | Admin hosting management | +| `*` | `/api/v1/admin/hosting/services/*` | Service management | +| `*` | `/api/v1/admin/hosting/sites/*` | Site management | +| `GET` | `/api/v1/admin/hosting/stats/*` | Hosting statistics | + +## Configuration + +No module-specific configuration. + +## Additional Documentation + +- [User Journeys](user-journeys.md) — Hosting lifecycle user journeys diff --git a/app/modules/hosting/docs/user-journeys.md b/app/modules/hosting/docs/user-journeys.md new file mode 100644 index 00000000..de19c42e --- /dev/null +++ b/app/modules/hosting/docs/user-journeys.md @@ -0,0 +1,502 @@ +# Hosting Module - User Journeys + +## Personas + +| # | Persona | Role / Auth | Description | +|---|---------|-------------|-------------| +| 1 | **Platform Admin** | `admin` role | Manages the POC → live website pipeline, tracks client services, monitors renewals | +| 2 | **Prospect** | No auth (receives proposal link) | Views their POC website preview via a shared link | + +!!! note "Admin-only module" + The hosting module is primarily an admin-only module. The only non-admin page is the + **POC Viewer** — a public preview page that shows the prospect's POC website with a + HostWizard banner. Prospects do not have accounts until their proposal is accepted, at + which point a Merchant account is created for them. + +--- + +## Lifecycle Overview + +The hosting module manages the complete POC → live website pipeline: + +```mermaid +flowchart TD + A[Prospect identified] --> B[Create Hosted Site] + B --> C[Status: DRAFT] + C --> D[Build POC website via CMS] + D --> E[Mark POC Ready] + E --> F[Status: POC_READY] + F --> G[Send Proposal to prospect] + G --> H[Status: PROPOSAL_SENT] + H --> I{Prospect accepts?} + I -->|Yes| J[Accept Proposal] + J --> K[Status: ACCEPTED] + K --> L[Merchant account created] + L --> M[Go Live with domain] + M --> N[Status: LIVE] + I -->|No| O[Cancel] + O --> P[Status: CANCELLED] + N --> Q{Issues?} + Q -->|Payment issues| R[Suspend] + R --> S[Status: SUSPENDED] + S --> T[Reactivate → LIVE] + Q -->|Client leaves| O +``` + +### Status Transitions + +| From | Allowed Targets | +|------|----------------| +| `draft` | `poc_ready`, `cancelled` | +| `poc_ready` | `proposal_sent`, `cancelled` | +| `proposal_sent` | `accepted`, `cancelled` | +| `accepted` | `live`, `cancelled` | +| `live` | `suspended`, `cancelled` | +| `suspended` | `live`, `cancelled` | +| `cancelled` | _(terminal)_ | + +--- + +## Dev URLs (localhost:9999) + +The dev server uses path-based platform routing: `http://localhost:9999/platforms/hosting/...` + +### 1. Admin Pages + +Login as: `admin@orion.lu` or `samir.boulahtit@gmail.com` + +| Page | Dev URL | +|------|---------| +| Dashboard | `http://localhost:9999/platforms/hosting/admin/hosting` | +| Sites List | `http://localhost:9999/platforms/hosting/admin/hosting/sites` | +| New Site | `http://localhost:9999/platforms/hosting/admin/hosting/sites/new` | +| Site Detail | `http://localhost:9999/platforms/hosting/admin/hosting/sites/{site_id}` | +| Client Services | `http://localhost:9999/platforms/hosting/admin/hosting/clients` | + +### 2. Public Pages + +| Page | Dev URL | +|------|---------| +| POC Viewer | `http://localhost:9999/platforms/hosting/hosting/sites/{site_id}/preview` | + +### 3. Admin API Endpoints + +**Sites** (prefix: `/platforms/hosting/api/admin/hosting/`): + +| Method | Endpoint | Dev URL | +|--------|----------|---------| +| GET | list sites | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites` | +| GET | site detail | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}` | +| POST | create site | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites` | +| POST | create from prospect | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/from-prospect/{prospect_id}` | +| PUT | update site | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}` | +| DELETE | delete site | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}` | + +**Lifecycle** (prefix: `/platforms/hosting/api/admin/hosting/`): + +| Method | Endpoint | Dev URL | +|--------|----------|---------| +| POST | mark POC ready | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/mark-poc-ready` | +| POST | send proposal | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/send-proposal` | +| POST | accept proposal | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/accept` | +| POST | go live | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/go-live` | +| POST | suspend | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/suspend` | +| POST | cancel | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/cancel` | + +**Client Services** (prefix: `/platforms/hosting/api/admin/hosting/`): + +| Method | Endpoint | Dev URL | +|--------|----------|---------| +| GET | list services | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{site_id}/services` | +| POST | create service | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{site_id}/services` | +| PUT | update service | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{site_id}/services/{id}` | +| DELETE | delete service | `http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{site_id}/services/{id}` | + +**Stats** (prefix: `/platforms/hosting/api/admin/hosting/`): + +| Method | Endpoint | Dev URL | +|--------|----------|---------| +| GET | dashboard stats | `http://localhost:9999/platforms/hosting/api/admin/hosting/stats/dashboard` | + +--- + +## Production URLs (hostwizard.lu) + +In production, the platform uses **domain-based routing**. + +### Admin Pages & API + +| Page / Endpoint | Production URL | +|-----------------|----------------| +| Dashboard | `https://hostwizard.lu/admin/hosting` | +| Sites | `https://hostwizard.lu/admin/hosting/sites` | +| New Site | `https://hostwizard.lu/admin/hosting/sites/new` | +| Site Detail | `https://hostwizard.lu/admin/hosting/sites/{id}` | +| Client Services | `https://hostwizard.lu/admin/hosting/clients` | +| API - Sites | `GET https://hostwizard.lu/api/admin/hosting/sites` | +| API - Stats | `GET https://hostwizard.lu/api/admin/hosting/stats/dashboard` | + +### Public Pages + +| Page | Production URL | +|------|----------------| +| POC Viewer | `https://hostwizard.lu/hosting/sites/{site_id}/preview` | + +--- + +## Data Model + +### Hosted Site + +``` +HostedSite +├── id (PK) +├── store_id (FK → stores.id, unique) # The CMS-powered website +├── prospect_id (FK → prospects.id, nullable) # Origin prospect +├── status: draft | poc_ready | proposal_sent | accepted | live | suspended | cancelled +├── business_name (str) +├── contact_name, contact_email, contact_phone +├── proposal_sent_at, proposal_accepted_at, went_live_at (datetime) +├── proposal_notes (text) +├── live_domain (str, unique) +├── internal_notes (text) +├── created_at, updated_at +└── Relationships: store, prospect, client_services +``` + +### Client Service + +``` +ClientService +├── id (PK) +├── hosted_site_id (FK → hosted_sites.id, CASCADE) +├── service_type: domain | email | ssl | hosting | website_maintenance +├── name (str) # e.g., "acme.lu domain", "5 mailboxes" +├── status: pending | active | suspended | expired | cancelled +├── billing_period: monthly | annual | one_time +├── price_cents (int), currency (str, default EUR) +├── addon_product_id (FK, nullable) # Link to billing product +├── domain_name, registrar # Domain-specific +├── mailbox_count # Email-specific +├── expires_at, period_start, period_end, auto_renew +├── notes (text) +└── created_at, updated_at +``` + +--- + +## User Journeys + +### Journey 1: Create Hosted Site from Prospect + +**Persona:** Platform Admin +**Goal:** Convert a qualified prospect into a hosted site with a POC website + +**Prerequisite:** A prospect exists in the prospecting module (see [Prospecting Journeys](../prospecting/user-journeys.md)) + +```mermaid +flowchart TD + A[View prospect in prospecting module] --> B[Click 'Create Hosted Site from Prospect'] + B --> C[HostedSite created with status DRAFT] + C --> D[Store auto-created on hosting platform] + D --> E[Contact info pre-filled from prospect] + E --> F[Navigate to site detail] + F --> G[Build POC website via CMS editor] +``` + +**Steps:** + +1. Create hosted site from prospect: + - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites/from-prospect/{prospect_id}` + - API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites/from-prospect/{prospect_id}` +2. This automatically: + - Creates a Store on the hosting platform + - Creates a HostedSite record linked to the Store and Prospect + - Pre-fills business_name, contact_name, contact_email, contact_phone from prospect data +3. View the new site: + - Dev: `http://localhost:9999/platforms/hosting/admin/hosting/sites/{site_id}` + - Prod: `https://hostwizard.lu/admin/hosting/sites/{site_id}` +4. Click the Store link to open the CMS editor and build the POC website + +--- + +### Journey 2: Create Hosted Site Manually + +**Persona:** Platform Admin +**Goal:** Create a hosted site without an existing prospect (e.g., direct referral) + +```mermaid +flowchart TD + A[Navigate to New Site page] --> B[Fill in business details] + B --> C[Submit form] + C --> D[HostedSite + Store created] + D --> E[Navigate to site detail] + E --> F[Build POC website] +``` + +**Steps:** + +1. Navigate to New Site form: + - Dev: `http://localhost:9999/platforms/hosting/admin/hosting/sites/new` + - Prod: `https://hostwizard.lu/admin/hosting/sites/new` +2. Create the site: + - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites` + - API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites` + - Body: `{ "business_name": "Boulangerie du Parc", "contact_name": "Jean Müller", "contact_email": "jean@boulangerie-parc.lu", "contact_phone": "+352 26 123 456" }` +3. A Store is auto-created with subdomain `boulangerie-du-parc` on the hosting platform + +--- + +### Journey 3: POC → Proposal Flow + +**Persona:** Platform Admin +**Goal:** Build a POC website, mark it ready, and send a proposal to the prospect + +```mermaid +flowchart TD + A[Site is DRAFT] --> B[Build POC website via CMS] + B --> C[Mark POC Ready] + C --> D[Site is POC_READY] + D --> E[Preview the POC site] + E --> F[Send Proposal with notes] + F --> G[Site is PROPOSAL_SENT] + G --> H[Share preview link with prospect] +``` + +**Steps:** + +1. Build the POC website using the Store's CMS editor (linked from site detail page) +2. When the POC is ready, mark it: + - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/mark-poc-ready` + - API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites/{id}/mark-poc-ready` +3. Preview the POC site (public link, no auth needed): + - Dev: `http://localhost:9999/platforms/hosting/hosting/sites/{id}/preview` + - Prod: `https://hostwizard.lu/hosting/sites/{id}/preview` +4. Send proposal to the prospect: + - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/send-proposal` + - API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites/{id}/send-proposal` + - Body: `{ "notes": "Custom website with 5 pages, domain registration included" }` +5. Share the preview link with the prospect via email + +!!! info "POC Viewer" + The POC Viewer page renders the Store's storefront in an iframe with a teal + HostWizard banner at the top. It only works for sites with status `poc_ready` + or `proposal_sent`. Once the site goes live, the preview is disabled. + +--- + +### Journey 4: Accept Proposal & Create Merchant + +**Persona:** Platform Admin +**Goal:** When a prospect accepts, create their merchant account and subscription + +```mermaid +flowchart TD + A[Prospect accepts proposal] --> B{Existing merchant?} + B -->|Yes| C[Link to existing merchant] + B -->|No| D[Auto-create merchant + owner account] + C --> E[Accept Proposal] + D --> E + E --> F[Site is ACCEPTED] + F --> G[Store reassigned to merchant] + G --> H[Subscription created on hosting platform] + H --> I[Prospect marked as CONVERTED] +``` + +**Steps:** + +1. Accept the proposal (auto-creates merchant if no merchant_id provided): + - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/accept` + - API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites/{id}/accept` + - Body: `{}` (auto-create merchant) or `{ "merchant_id": 5 }` (link to existing) +2. This automatically: + - Creates a new Merchant from contact info (name, email, phone) + - Creates a store owner account with a temporary password + - Reassigns the Store from the system merchant to the new merchant + - Creates a MerchantSubscription on the hosting platform (essential tier) + - Marks the linked prospect as CONVERTED (if prospect_id is set) +3. View the updated site detail: + - Dev: `http://localhost:9999/platforms/hosting/admin/hosting/sites/{id}` + - Prod: `https://hostwizard.lu/admin/hosting/sites/{id}` + +!!! warning "Merchant account credentials" + When accepting without an existing `merchant_id`, a new merchant owner account is + created with a temporary password. The admin should communicate these credentials + to the client so they can log in and self-edit their website via the CMS. + +--- + +### Journey 5: Go Live with Custom Domain + +**Persona:** Platform Admin +**Goal:** Assign a production domain to the website and make it live + +```mermaid +flowchart TD + A[Site is ACCEPTED] --> B[Configure DNS for client domain] + B --> C[Go Live with domain] + C --> D[Site is LIVE] + D --> E[StoreDomain created] + E --> F[Website accessible at client domain] +``` + +**Steps:** + +1. Ensure DNS is configured for the client's domain (A/AAAA records pointing to the server) +2. Go live: + - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/go-live` + - API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites/{id}/go-live` + - Body: `{ "domain": "boulangerie-parc.lu" }` +3. This automatically: + - Sets `went_live_at` timestamp + - Creates a StoreDomain record (primary) for the domain + - Sets `live_domain` on the hosted site +4. The website is now accessible at `https://boulangerie-parc.lu` + +--- + +### Journey 6: Add Client Services + +**Persona:** Platform Admin +**Goal:** Track operational services (domains, email, SSL, hosting) for a client + +```mermaid +flowchart TD + A[Open site detail] --> B[Go to Services tab] + B --> C[Add domain service] + C --> D[Add email service] + D --> E[Add SSL service] + E --> F[Add hosting service] + F --> G[Services tracked with expiry dates] +``` + +**Steps:** + +1. Navigate to site detail, Services tab: + - Dev: `http://localhost:9999/platforms/hosting/admin/hosting/sites/{site_id}` + - Prod: `https://hostwizard.lu/admin/hosting/sites/{site_id}` +2. Add a domain service: + - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{site_id}/services` + - API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites/{site_id}/services` + - Body: `{ "service_type": "domain", "name": "boulangerie-parc.lu domain", "domain_name": "boulangerie-parc.lu", "registrar": "Namecheap", "billing_period": "annual", "price_cents": 1500, "expires_at": "2027-03-01T00:00:00", "auto_renew": true }` +3. Add an email service: + - Body: `{ "service_type": "email", "name": "5 mailboxes", "mailbox_count": 5, "billing_period": "monthly", "price_cents": 999 }` +4. Add an SSL service: + - Body: `{ "service_type": "ssl", "name": "SSL certificate", "billing_period": "annual", "price_cents": 0, "expires_at": "2027-03-01T00:00:00" }` +5. View all services for a site: + - API Dev: `GET http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{site_id}/services` + - API Prod: `GET https://hostwizard.lu/api/admin/hosting/sites/{site_id}/services` + +--- + +### Journey 7: Dashboard & Renewal Monitoring + +**Persona:** Platform Admin +**Goal:** Monitor business KPIs and upcoming service renewals + +```mermaid +flowchart TD + A[Navigate to Dashboard] --> B[View KPIs] + B --> C[Total sites, live sites, POC sites] + C --> D[Monthly revenue] + D --> E[Active services count] + E --> F[Upcoming renewals in 30 days] + F --> G[Navigate to Client Services] + G --> H[Filter by expiring soon] + H --> I[Renew or update services] +``` + +**Steps:** + +1. Navigate to Dashboard: + - Dev: `http://localhost:9999/platforms/hosting/admin/hosting` + - Prod: `https://hostwizard.lu/admin/hosting` +2. View dashboard stats: + - API Dev: `GET http://localhost:9999/platforms/hosting/api/admin/hosting/stats/dashboard` + - API Prod: `GET https://hostwizard.lu/api/admin/hosting/stats/dashboard` + - Returns: `total_sites`, `live_sites`, `poc_sites`, `sites_by_status`, `active_services`, `monthly_revenue_cents`, `upcoming_renewals`, `services_by_type` +3. Navigate to Client Services for detailed view: + - Dev: `http://localhost:9999/platforms/hosting/admin/hosting/clients` + - Prod: `https://hostwizard.lu/admin/hosting/clients` +4. Filter by type (domain, email, ssl, hosting) or status +5. Toggle "Expiring Soon" to see services expiring within 30 days + +--- + +### Journey 8: Suspend & Reactivate + +**Persona:** Platform Admin +**Goal:** Handle suspension (e.g., unpaid invoices) and reactivation + +**Steps:** + +1. Suspend a site: + - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/suspend` + - API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites/{id}/suspend` +2. Site status changes to `suspended` +3. Once payment is resolved, reactivate by transitioning back to live: + - The `suspended → live` transition is allowed +4. To permanently close a site: + - API Dev: `POST http://localhost:9999/platforms/hosting/api/admin/hosting/sites/{id}/cancel` + - API Prod: `POST https://hostwizard.lu/api/admin/hosting/sites/{id}/cancel` +5. `cancelled` is a terminal state — no further transitions allowed + +--- + +### Journey 9: Complete Pipeline (Prospect → Live Site) + +**Persona:** Platform Admin +**Goal:** Walk the complete pipeline from prospect to live website + +This journey combines the prospecting and hosting modules end-to-end: + +```mermaid +flowchart TD + A[Import domain / capture lead] --> B[Enrich & score prospect] + B --> C[Create hosted site from prospect] + C --> D[Build POC website via CMS] + D --> E[Mark POC ready] + E --> F[Send proposal + share preview link] + F --> G{Prospect accepts?} + G -->|Yes| H[Accept → Merchant created] + H --> I[Add client services] + I --> J[Go live with domain] + J --> K[Website live at client domain] + K --> L[Monitor renewals & services] + G -->|No| M[Cancel or follow up later] +``` + +**Steps:** + +1. **Prospecting phase** (see [Prospecting Journeys](../prospecting/user-journeys.md)): + - Import domain or capture lead offline + - Run enrichment pipeline + - Score and qualify the prospect +2. **Create hosted site**: `POST /api/admin/hosting/sites/from-prospect/{prospect_id}` +3. **Build POC**: Edit the auto-created Store via CMS +4. **Mark POC ready**: `POST /api/admin/hosting/sites/{id}/mark-poc-ready` +5. **Send proposal**: `POST /api/admin/hosting/sites/{id}/send-proposal` +6. **Share preview**: Send `https://hostwizard.lu/hosting/sites/{id}/preview` to prospect +7. **Accept proposal**: `POST /api/admin/hosting/sites/{id}/accept` +8. **Add services**: `POST /api/admin/hosting/sites/{id}/services` (domain, email, SSL, hosting) +9. **Go live**: `POST /api/admin/hosting/sites/{id}/go-live` with domain +10. **Monitor**: Dashboard at `https://hostwizard.lu/admin/hosting` + +--- + +## Recommended Test Order + +1. **Journey 2** - Create a site manually first (simplest path, no prospect dependency) +2. **Journey 3** - Walk the POC → proposal flow +3. **Journey 4** - Accept proposal and verify merchant creation +4. **Journey 5** - Go live with a test domain +5. **Journey 6** - Add client services +6. **Journey 7** - Check dashboard stats +7. **Journey 1** - Test the prospect → hosted site conversion (requires prospecting data) +8. **Journey 8** - Test suspend/reactivate/cancel +9. **Journey 9** - Walk the complete end-to-end pipeline + +!!! tip "Test Journey 2 before Journey 1" + Journey 2 (manual creation) doesn't require any prospecting data and is the fastest + way to verify the hosting module works. Journey 1 (from prospect) requires running + the prospecting module first. diff --git a/app/modules/inventory/docs/data-model.md b/app/modules/inventory/docs/data-model.md new file mode 100644 index 00000000..b6b4368b --- /dev/null +++ b/app/modules/inventory/docs/data-model.md @@ -0,0 +1,82 @@ +# Inventory Data Model + +Entity relationships and database schema for the inventory module. + +## Entity Relationship Overview + +``` +Store 1──* Inventory *──1 Product + │ + └──* InventoryTransaction + │ + └──? Order (for reserve/fulfill/release) +``` + +## Models + +### Inventory + +Stock quantities at warehouse bin locations. Supports multi-location inventory with reservation tracking. + +| Field | Type | Constraints | Description | +|-------|------|-------------|-------------| +| `id` | Integer | PK | Primary key | +| `product_id` | Integer | FK, not null, indexed | Product reference | +| `store_id` | Integer | FK, not null, indexed | Store reference | +| `warehouse` | String | not null, default "strassen", indexed | Warehouse identifier | +| `bin_location` | String | not null, indexed | Bin code (e.g., "SA-10-02") | +| `quantity` | Integer | not null, default 0 | Total quantity at bin | +| `reserved_quantity` | Integer | default 0 | Reserved/allocated quantity | +| `gtin` | String | indexed | GTIN reference (duplicated for reporting) | +| `created_at` | DateTime | tz-aware | Record creation time | +| `updated_at` | DateTime | tz-aware | Record update time | + +**Unique Constraint**: `(product_id, warehouse, bin_location)` +**Composite Indexes**: `(store_id, product_id)`, `(warehouse, bin_location)` + +**Key Property**: `available_quantity` = max(0, quantity - reserved_quantity) + +### InventoryTransaction + +Complete audit trail for all stock movements with before/after snapshots. + +| Field | Type | Constraints | Description | +|-------|------|-------------|-------------| +| `id` | Integer | PK | Primary key | +| `store_id` | Integer | FK, not null, indexed | Store reference | +| `product_id` | Integer | FK, not null, indexed | Product reference | +| `inventory_id` | Integer | FK, nullable, indexed | Inventory record reference | +| `transaction_type` | Enum | not null, indexed | Type of stock movement | +| `quantity_change` | Integer | not null | Change amount (+ add, - remove) | +| `quantity_after` | Integer | not null | Quantity snapshot after transaction | +| `reserved_after` | Integer | not null, default 0 | Reserved quantity snapshot | +| `location` | String | nullable | Location context | +| `warehouse` | String | nullable | Warehouse context | +| `order_id` | Integer | FK, nullable, indexed | Related order | +| `order_number` | String | nullable | Order number for display | +| `reason` | Text | nullable | Human-readable reason | +| `created_by` | String | nullable | User/system identifier | +| `created_at` | DateTime | not null, indexed | Timestamp (UTC) | + +**Composite Indexes**: `(store_id, product_id)`, `(store_id, created_at)`, `(transaction_type, created_at)` + +## Enums + +### TransactionType + +| Value | Description | +|-------|-------------| +| `reserve` | Stock reserved for order | +| `fulfill` | Reserved stock consumed (shipped) | +| `release` | Reserved stock released (cancelled) | +| `adjust` | Manual adjustment (+/-) | +| `set` | Set to exact quantity | +| `import` | Initial import/sync | +| `return` | Stock returned from customer | + +## Design Patterns + +- **Multi-location**: Inventory tracked per warehouse + bin location +- **Reservation system**: Separate quantity and reserved_quantity for order holds +- **Full audit trail**: Every stock change recorded with before/after snapshots +- **Order integration**: Transactions linked to orders for reserve/fulfill/release cycle diff --git a/app/modules/inventory/docs/index.md b/app/modules/inventory/docs/index.md new file mode 100644 index 00000000..06882699 --- /dev/null +++ b/app/modules/inventory/docs/index.md @@ -0,0 +1,53 @@ +# Inventory Management + +Stock level tracking, inventory locations, low stock alerts, transaction history, and bulk imports. + +## Overview + +| Aspect | Detail | +|--------|--------| +| Code | `inventory` | +| Classification | Optional | +| Dependencies | `catalog`, `orders` | +| Status | Active | + +## Features + +- `inventory_basic` — Basic stock tracking +- `inventory_locations` — Multi-location inventory +- `low_stock_alerts` — Low stock notifications +- `inventory_purchase_orders` — Purchase order management +- `product_management` — Product inventory management +- `inventory_transactions` — Stock movement audit trail +- `inventory_import` — Bulk stock import + +## Permissions + +| Permission | Description | +|------------|-------------| +| `stock.view` | View inventory data | +| `stock.edit` | Edit stock levels | +| `stock.transfer` | Transfer stock between locations | + +## Data Model + +See [Data Model](data-model.md) for full entity relationships and schema. + +- **Inventory** — Stock quantities at warehouse bin locations +- **InventoryTransaction** — Complete audit trail for stock movements + +## API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `*` | `/api/v1/admin/inventory/*` | Admin inventory management | +| `*` | `/api/v1/store/inventory/*` | Store inventory management | + +## Configuration + +No module-specific configuration. + +## Additional Documentation + +- [Data Model](data-model.md) — Entity relationships and database schema +- [User Guide](user-guide.md) — Inventory management guide with API reference diff --git a/app/modules/inventory/docs/user-guide.md b/app/modules/inventory/docs/user-guide.md new file mode 100644 index 00000000..2836f99d --- /dev/null +++ b/app/modules/inventory/docs/user-guide.md @@ -0,0 +1,366 @@ +# Inventory Management + +## Overview + +The Orion platform provides comprehensive inventory management with support for: + +- **Multi-location tracking** - Track stock across warehouses, stores, and storage bins +- **Reservation system** - Reserve items for pending orders +- **Digital products** - Automatic unlimited inventory for digital goods +- **Admin operations** - Manage inventory on behalf of stores + +--- + +## Key Concepts + +### Storage Locations + +Inventory is tracked at the **storage location level**. Each product can have stock in multiple locations: + +``` +Product: "Wireless Headphones" +├── WAREHOUSE_MAIN: 100 units (10 reserved) +├── WAREHOUSE_WEST: 50 units (0 reserved) +└── STORE_FRONT: 25 units (5 reserved) + +Total: 175 units | Reserved: 15 | Available: 160 +``` + +**Location naming:** Locations are text strings, normalized to UPPERCASE (e.g., `WAREHOUSE_A`, `STORE_01`). + +### Inventory States + +| Field | Description | +|-------|-------------| +| `quantity` | Total physical stock at location | +| `reserved_quantity` | Items reserved for pending orders | +| `available_quantity` | `quantity - reserved_quantity` (can be sold) | + +### Product Types & Inventory + +| Product Type | Inventory Behavior | +|--------------|-------------------| +| **Physical** | Requires inventory tracking, orders check available stock | +| **Digital** | **Unlimited inventory** - no stock constraints | +| **Service** | Treated as digital (unlimited) | +| **Subscription** | Treated as digital (unlimited) | + +--- + +## Digital Products + +Digital products have **unlimited inventory** by default. This means: + +- Orders for digital products never fail due to "insufficient inventory" +- No need to create inventory entries for digital products +- The `available_inventory` property returns `999999` (effectively unlimited) + +### How It Works + +```python +# In Product model +@property +def has_unlimited_inventory(self) -> bool: + """Digital products have unlimited inventory.""" + return self.is_digital + +@property +def available_inventory(self) -> int: + """Calculate available inventory.""" + if self.has_unlimited_inventory: + return 999999 # Unlimited + return sum(inv.available_quantity for inv in self.inventory_entries) +``` + +### Setting a Product as Digital + +Digital products are identified by the `is_digital` flag on the `MarketplaceProduct`: + +```python +marketplace_product.is_digital = True +marketplace_product.product_type_enum = "digital" +marketplace_product.digital_delivery_method = "license_key" # or "download", "email" +``` + +--- + +## Inventory Operations + +### Set Inventory + +Replace the exact quantity at a location: + +```http +POST /api/v1/store/inventory/set +{ + "product_id": 123, + "location": "WAREHOUSE_A", + "quantity": 100 +} +``` + +### Adjust Inventory + +Add or remove stock (positive = add, negative = remove): + +```http +POST /api/v1/store/inventory/adjust +{ + "product_id": 123, + "location": "WAREHOUSE_A", + "quantity": -10 // Remove 10 units +} +``` + +### Reserve Inventory + +Mark items as reserved for an order: + +```http +POST /api/v1/store/inventory/reserve +{ + "product_id": 123, + "location": "WAREHOUSE_A", + "quantity": 5 +} +``` + +### Release Reservation + +Cancel a reservation (order cancelled): + +```http +POST /api/v1/store/inventory/release +{ + "product_id": 123, + "location": "WAREHOUSE_A", + "quantity": 5 +} +``` + +### Fulfill Reservation + +Complete an order (items shipped): + +```http +POST /api/v1/store/inventory/fulfill +{ + "product_id": 123, + "location": "WAREHOUSE_A", + "quantity": 5 +} +``` + +This decreases both `quantity` and `reserved_quantity`. + +--- + +## Reservation Workflow + +``` +┌─────────────────┐ +│ Order Created │ +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ Reserve Items │ reserved_quantity += order_qty +└────────┬────────┘ + │ + ┌────┴────┐ + │ │ + ▼ ▼ +┌───────┐ ┌──────────┐ +│Cancel │ │ Ship │ +└───┬───┘ └────┬─────┘ + │ │ + ▼ ▼ +┌─────────┐ ┌──────────────┐ +│ Release │ │ Fulfill │ +│reserved │ │ quantity -= │ +│ -= qty │ │ reserved -= │ +└─────────┘ └──────────────┘ +``` + +--- + +## Admin Inventory Management + +Administrators can manage inventory on behalf of any store through the admin UI at `/admin/inventory` or via the API. + +### Admin UI Features + +The admin inventory page provides: + +- **Overview Statistics** - Total entries, stock quantities, reserved items, and low stock alerts +- **Filtering** - Filter by store, location, and low stock threshold +- **Search** - Search by product title or SKU +- **Stock Adjustment** - Add or remove stock with optional reason tracking +- **Set Quantity** - Set exact stock quantity at any location +- **Delete Entries** - Remove inventory entries + +### Admin API Endpoints + +### List All Inventory + +```http +GET /api/v1/admin/inventory +GET /api/v1/admin/inventory?store_id=1 +GET /api/v1/admin/inventory?low_stock=10 +``` + +### Get Inventory Statistics + +```http +GET /api/v1/admin/inventory/stats + +Response: +{ + "total_entries": 150, + "total_quantity": 5000, + "total_reserved": 200, + "total_available": 4800, + "low_stock_count": 12, + "stores_with_inventory": 5, + "unique_locations": 8 +} +``` + +### Low Stock Alerts + +```http +GET /api/v1/admin/inventory/low-stock?threshold=10 + +Response: +[ + { + "product_id": 123, + "store_name": "TechStore", + "product_title": "USB Cable", + "location": "WAREHOUSE_A", + "quantity": 3, + "available_quantity": 2 + } +] +``` + +### Set Inventory (Admin) + +```http +POST /api/v1/admin/inventory/set +{ + "store_id": 1, + "product_id": 123, + "location": "WAREHOUSE_A", + "quantity": 100 +} +``` + +### Adjust Inventory (Admin) + +```http +POST /api/v1/admin/inventory/adjust +{ + "store_id": 1, + "product_id": 123, + "location": "WAREHOUSE_A", + "quantity": 25, + "reason": "Restocking from supplier" +} +``` + +--- + +## Database Schema + +### Inventory Table + +```sql +CREATE TABLE inventory ( + id SERIAL PRIMARY KEY, + product_id INTEGER NOT NULL REFERENCES products(id), + store_id INTEGER NOT NULL REFERENCES stores(id), + location VARCHAR NOT NULL, + quantity INTEGER NOT NULL DEFAULT 0, + reserved_quantity INTEGER DEFAULT 0, + gtin VARCHAR, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + + UNIQUE(product_id, location) +); + +CREATE INDEX idx_inventory_store_product ON inventory(store_id, product_id); +CREATE INDEX idx_inventory_product_location ON inventory(product_id, location); +``` + +### Constraints + +- **Unique constraint:** `(product_id, location)` - One entry per product/location +- **Foreign keys:** References `products` and `stores` tables +- **Non-negative:** `quantity` and `reserved_quantity` must be >= 0 + +--- + +## Best Practices + +### Physical Products + +1. **Create inventory entries** before accepting orders +2. **Use meaningful location names** (e.g., `WAREHOUSE_MAIN`, `STORE_NYC`) +3. **Monitor low stock** using the admin dashboard or API +4. **Reserve on order creation** to prevent overselling + +### Digital Products + +1. **No inventory setup needed** - unlimited by default +2. **Optional:** Create entries for license key tracking +3. **Focus on fulfillment** - digital delivery mechanism + +### Multi-Location + +1. **Aggregate queries** use `Product.total_inventory` and `Product.available_inventory` +2. **Location-specific** operations use the Inventory model directly +3. **Transfers** between locations: adjust down at source, adjust up at destination + +--- + +## API Reference + +### Store Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/v1/store/inventory/set` | Set exact quantity | +| POST | `/api/v1/store/inventory/adjust` | Add/remove quantity | +| POST | `/api/v1/store/inventory/reserve` | Reserve for order | +| POST | `/api/v1/store/inventory/release` | Cancel reservation | +| POST | `/api/v1/store/inventory/fulfill` | Complete order | +| GET | `/api/v1/store/inventory/product/{id}` | Product summary | +| GET | `/api/v1/store/inventory` | List with filters | +| PUT | `/api/v1/store/inventory/{id}` | Update entry | +| DELETE | `/api/v1/store/inventory/{id}` | Delete entry | + +### Admin Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/admin/inventory` | List all (cross-store) | +| GET | `/api/v1/admin/inventory/stats` | Platform statistics | +| GET | `/api/v1/admin/inventory/low-stock` | Low stock alerts | +| GET | `/api/v1/admin/inventory/stores` | Stores with inventory | +| GET | `/api/v1/admin/inventory/locations` | Unique locations | +| GET | `/api/v1/admin/inventory/stores/{id}` | Store inventory | +| GET | `/api/v1/admin/inventory/products/{id}` | Product summary | +| POST | `/api/v1/admin/inventory/set` | Set (requires store_id) | +| POST | `/api/v1/admin/inventory/adjust` | Adjust (requires store_id) | +| PUT | `/api/v1/admin/inventory/{id}` | Update entry | +| DELETE | `/api/v1/admin/inventory/{id}` | Delete entry | + +--- + +## Related Documentation + +- [Product Management](../catalog/architecture.md) +- [Admin Inventory Migration Plan](../../implementation/inventory-admin-migration.md) +- [Store Operations Expansion](../../development/migration/store-operations-expansion.md) diff --git a/app/modules/loyalty/docs/business-logic.md b/app/modules/loyalty/docs/business-logic.md new file mode 100644 index 00000000..dc0e9798 --- /dev/null +++ b/app/modules/loyalty/docs/business-logic.md @@ -0,0 +1,264 @@ +# Loyalty Business Logic + +Core algorithms, anti-fraud systems, and wallet integration logic for the loyalty module. + +## Anti-Fraud System + +The loyalty module implements a multi-layer fraud prevention system to prevent abuse of stamp and points operations. + +### Layer 1: Staff PIN Verification + +Every stamp/points operation can require a staff PIN. PINs are bcrypt-hashed and scoped to a specific store within a merchant. + +**Flow:** +1. Staff enters 4-digit PIN on terminal +2. System checks all active PINs for the program +3. On match: records success, updates `last_used_at` +4. On mismatch: increments `failed_attempts` +5. After N failures (configurable, default 5): PIN is locked for M minutes (default 30) + +**PIN Policy** (set via `MerchantLoyaltySettings.staff_pin_policy`): + +| Policy | Behavior | +|--------|----------| +| `REQUIRED` | All stamp/point operations require PIN | +| `OPTIONAL` | PIN can be provided but not required | +| `DISABLED` | PIN entry is hidden from UI | + +### Layer 2: Stamp Cooldown + +Prevents rapid-fire stamping (e.g., customer stamps 10 times in one visit). + +- Configurable via `LoyaltyProgram.cooldown_minutes` (default: 15) +- Checks `LoyaltyCard.last_stamp_at` against current time +- Returns `next_stamp_available` timestamp in response + +### Layer 3: Daily Stamp Limits + +Prevents excessive stamps per day per card. + +- Configurable via `LoyaltyProgram.max_daily_stamps` (default: 5) +- Counts today's `STAMP_EARNED` transactions for the card +- Returns `remaining_stamps_today` in response + +### Layer 4: Audit Trail + +Every transaction records: +- `staff_pin_id` — Which staff member verified +- `store_id` — Which location +- `ip_address` — Client IP (if `log_ip_addresses` enabled) +- `user_agent` — Client device +- `transaction_at` — Exact timestamp + +## Stamp Operations + +### Adding a Stamp + +``` +Input: card_id, staff_pin (optional), store_id +Checks: + 1. Card is active + 2. Program is active and stamps-enabled + 3. Staff PIN valid (if required by policy) + 4. Cooldown elapsed since last_stamp_at + 5. Daily limit not reached +Action: + - card.stamp_count += 1 + - card.total_stamps_earned += 1 + - card.last_stamp_at = now + - Create STAMP_EARNED transaction + - Sync wallet passes +Output: + - stamp_count, stamps_target, stamps_until_reward + - reward_earned (true if stamp_count >= target) + - next_stamp_available, remaining_stamps_today +``` + +### Redeeming Stamps + +``` +Input: card_id, staff_pin (optional), store_id +Checks: + 1. stamp_count >= stamps_target + 2. Staff PIN valid (if required) +Action: + - card.stamp_count -= stamps_target (keeps overflow stamps) + - card.stamps_redeemed += 1 + - Create STAMP_REDEEMED transaction (with reward_description) + - Sync wallet passes +Output: + - success, reward_description, redemption_count + - remaining stamp_count after reset +``` + +### Voiding Stamps + +``` +Input: card_id, stamps_count OR transaction_id, staff_pin, store_id +Checks: + 1. allow_void_transactions enabled in merchant settings + 2. Card has enough stamps to void + 3. Staff PIN valid (if required) +Action: + - card.stamp_count -= stamps_count + - Create STAMP_VOIDED transaction (linked to original via related_transaction_id) + - Sync wallet passes +``` + +## Points Operations + +### Earning Points + +``` +Input: card_id, purchase_amount_cents, staff_pin, store_id, order_reference +Calculation: + euros = purchase_amount_cents / 100 + points = floor(euros × program.points_per_euro) +Checks: + 1. Card is active, program is active and points-enabled + 2. Purchase amount >= minimum_purchase_cents (if configured) + 3. Order reference provided (if require_order_reference enabled) + 4. Staff PIN valid (if required) +Action: + - card.points_balance += points + - card.total_points_earned += points + - Create POINTS_EARNED transaction (with purchase_amount_cents) + - Sync wallet passes +Output: + - points_earned, points_balance, purchase_amount, points_per_euro +``` + +### Redeeming Points + +``` +Input: card_id, reward_id, staff_pin, store_id +Checks: + 1. Reward exists in program.points_rewards + 2. card.points_balance >= reward.points_cost + 3. points_balance >= minimum_redemption_points (if configured) + 4. Staff PIN valid (if required) +Action: + - card.points_balance -= reward.points_cost + - card.points_redeemed += reward.points_cost + - Create POINTS_REDEEMED transaction (with reward_id, reward_description) + - Sync wallet passes +Output: + - reward name/description, points_spent, new balance +``` + +### Voiding Points + +``` +Input: card_id, transaction_id OR order_reference, staff_pin, store_id +Checks: + 1. allow_void_transactions enabled + 2. Original transaction found and is an earn transaction + 3. Staff PIN valid (if required) +Action: + - card.points_balance -= original points + - card.total_points_voided += original points + - Create POINTS_VOIDED transaction (linked via related_transaction_id) + - Sync wallet passes +``` + +### Adjusting Points + +Admin/store operation for manual corrections. + +``` +Input: card_id, points_delta (positive or negative), notes, store_id +Action: + - card.points_balance += points_delta + - Create POINTS_ADJUSTMENT transaction with notes + - Sync wallet passes +``` + +## Wallet Integration + +### Google Wallet + +Uses the Google Wallet API with a service account for server-to-server communication. + +**Class (Program-level):** +- One `LoyaltyClass` per program +- Contains program name, branding (logo, hero), rewards info +- Created when program is activated; updated when settings change + +**Object (Card-level):** +- One `LoyaltyObject` per card +- Contains balance (stamps or points), card number, member name +- Created on enrollment; updated on every balance change +- "Add to Wallet" URL is a JWT-signed save link + +### Apple Wallet + +Uses PKCS#7 signed `.pkpass` files and APNs push notifications. + +**Pass Generation:** +1. Build `pass.json` with card data (stamps grid or points balance) +2. Add icon/logo/strip images +3. Create `manifest.json` (SHA256 of all files) +4. Sign manifest with PKCS#7 using certificates and private key +5. Package as `.pkpass` ZIP file + +**Push Updates:** +1. When card balance changes, send APNs push to all registered devices +2. Device receives push → requests updated pass from server +3. Server generates fresh `.pkpass` with current balance + +**Device Registration (Apple Web Service protocol):** +- `POST /v1/devices/{device}/registrations/{passType}/{serial}` — Register device +- `DELETE /v1/devices/{device}/registrations/{passType}/{serial}` — Unregister device +- `GET /v1/devices/{device}/registrations/{passType}` — List passes for device +- `GET /v1/passes/{passType}/{serial}` — Get latest pass + +## Cross-Store Redemption + +When `allow_cross_location_redemption` is enabled in merchant settings: + +- Cards are scoped to the **merchant** (not individual stores) +- Customer can earn stamps at Store A and redeem at Store B +- Each transaction records which `store_id` it occurred at +- The `enrolled_at_store_id` field tracks where the customer first enrolled + +When disabled, stamp/point operations are restricted to the enrollment store. + +## Enrollment Flow + +### Store-Initiated Enrollment + +Staff enrolls customer via terminal: +1. Enter customer email (and optional name) +2. System resolves or creates customer record +3. Creates loyalty card with unique card number and QR code +4. Creates `CARD_CREATED` transaction +5. Awards welcome bonus points (if configured) via `WELCOME_BONUS` transaction +6. Creates Google Wallet object and Apple Wallet serial +7. Returns card details with "Add to Wallet" URLs + +### Self-Enrollment (Public) + +Customer enrolls via public page (if `allow_self_enrollment` enabled): +1. Customer visits `/loyalty/join` page +2. Enters email and name +3. System creates customer + card +4. Redirected to success page with card number +5. Can add to Google/Apple Wallet from success page + +## Scheduled Tasks + +| Task | Schedule | Logic | +|------|----------|-------| +| `loyalty.sync_wallet_passes` | Hourly | Re-sync cards that missed real-time wallet updates | +| `loyalty.expire_points` | Daily 02:00 | Find cards with `points_expiration_days` set and no activity within that window; create `POINTS_EXPIRED` transaction | + +## Feature Gating + +The loyalty module declares these billable features via `LoyaltyFeatureProvider`: + +- `loyalty_stamps`, `loyalty_points`, `loyalty_hybrid` +- `loyalty_cards`, `loyalty_enrollment`, `loyalty_staff_pins` +- `loyalty_anti_fraud`, `loyalty_google_wallet`, `loyalty_apple_wallet` +- `loyalty_stats`, `loyalty_reports` + +These integrate with the [billing module's feature gating system](../billing/feature-gating.md) to control access based on subscription tier. diff --git a/app/modules/loyalty/docs/data-model.md b/app/modules/loyalty/docs/data-model.md new file mode 100644 index 00000000..b1f07ed4 --- /dev/null +++ b/app/modules/loyalty/docs/data-model.md @@ -0,0 +1,235 @@ +# Loyalty Data Model + +Entity relationships and database schema for the loyalty module. + +## Entity Relationship Diagram + +``` +┌──────────────────────┐ +│ Merchant │ (from tenancy module) +│ (one program per │ +│ merchant) │ +└──────────┬───────────┘ + │ 1 + │ + ┌─────┴─────┐ + │ │ + ▼ 1 ▼ 1 +┌──────────┐ ┌──────────────────────┐ +│ Loyalty │ │ MerchantLoyalty │ +│ Program │ │ Settings │ +│ │ │ │ +│ type │ │ staff_pin_policy │ +│ stamps │ │ allow_self_enrollment│ +│ points │ │ allow_void │ +│ branding │ │ allow_cross_location │ +│ anti- │ │ require_order_ref │ +│ fraud │ │ log_ip_addresses │ +└──┬───┬───┘ └──────────────────────┘ + │ │ + │ │ 1..* + │ ▼ + │ ┌──────────────┐ + │ │ StaffPin │ + │ │ │ + │ │ name │ + │ │ pin_hash │ (bcrypt) + │ │ store_id │ + │ │ failed_ │ + │ │ attempts │ + │ │ locked_until │ + │ └──────────────┘ + │ + │ 1..* + ▼ +┌──────────────────┐ ┌──────────────────┐ +│ LoyaltyCard │ │ Customer │ (from customers module) +│ │ *───1 │ │ +│ card_number │ └──────────────────┘ +│ qr_code_data │ +│ stamp_count │ ┌──────────────────┐ +│ points_balance │ │ Store │ (from tenancy module) +│ google_object_id│ *───1 │ (enrolled_at) │ +│ apple_serial │ └──────────────────┘ +│ is_active │ +└──────┬───────────┘ + │ + │ 1..* + ▼ +┌──────────────────────┐ +│ LoyaltyTransaction │ (immutable audit log) +│ │ +│ transaction_type │ +│ stamps_delta │ (signed: +1 earn, -N redeem) +│ points_delta │ (signed: +N earn, -N redeem) +│ stamps_balance_after│ +│ points_balance_after│ +│ purchase_amount │ +│ staff_pin_id │──── FK to StaffPin +│ store_id │──── FK to Store (location) +│ related_txn_id │──── FK to self (for voids) +│ ip_address │ +│ user_agent │ +└──────────────────────┘ + +┌──────────────────────────┐ +│ AppleDeviceRegistration │ +│ │ +│ card_id │──── FK to LoyaltyCard +│ device_library_id │ +│ push_token │ +│ │ +│ UNIQUE(device, card) │ +└──────────────────────────┘ +``` + +## Models + +### LoyaltyProgram + +Merchant-wide loyalty program configuration. One program per merchant, shared across all stores. + +| Field | Type | Description | +|-------|------|-------------| +| `merchant_id` | FK (unique) | One program per merchant | +| `loyalty_type` | Enum | STAMPS, POINTS, or HYBRID | +| `stamps_target` | Integer | Stamps needed for reward | +| `stamps_reward_description` | String | Reward description text | +| `stamps_reward_value_cents` | Integer | Reward monetary value | +| `points_per_euro` | Integer | Points earned per euro spent | +| `points_rewards` | JSON | Reward catalog (id, name, points_cost) | +| `points_expiration_days` | Integer | Days until points expire (nullable) | +| `welcome_bonus_points` | Integer | Points given on enrollment | +| `minimum_redemption_points` | Integer | Minimum points to redeem | +| `minimum_purchase_cents` | Integer | Minimum purchase for earning | +| `cooldown_minutes` | Integer | Minutes between stamps (anti-fraud) | +| `max_daily_stamps` | Integer | Max stamps per card per day | +| `require_staff_pin` | Boolean | Whether PIN is required | +| `card_name` | String | Display name on card | +| `card_color` | String | Primary brand color (hex) | +| `card_secondary_color` | String | Secondary brand color (hex) | +| `logo_url` | String | Logo image URL | +| `hero_image_url` | String | Hero/banner image URL | +| `google_issuer_id` | String | Google Wallet issuer ID | +| `google_class_id` | String | Google Wallet class ID | +| `apple_pass_type_id` | String | Apple Wallet pass type identifier | +| `terms_text` | Text | Terms and conditions | +| `privacy_url` | String | Privacy policy URL | +| `is_active` | Boolean | Whether program is live | +| `activated_at` | DateTime | When program was activated | + +### LoyaltyCard + +Customer loyalty card linking a customer to a merchant's program. One card per customer per merchant. + +| Field | Type | Description | +|-------|------|-------------| +| `merchant_id` | FK | Links to program's merchant | +| `customer_id` | FK | Card owner | +| `program_id` | FK | Associated program | +| `enrolled_at_store_id` | FK | Store where customer enrolled | +| `card_number` | String (unique) | Formatted XXXX-XXXX-XXXX | +| `qr_code_data` | String (unique) | URL-safe token for QR codes | +| `stamp_count` | Integer | Current stamp count | +| `total_stamps_earned` | Integer | Lifetime stamps earned | +| `stamps_redeemed` | Integer | Total redemptions | +| `points_balance` | Integer | Current points balance | +| `total_points_earned` | Integer | Lifetime points earned | +| `points_redeemed` | Integer | Total points redeemed | +| `total_points_voided` | Integer | Total points voided | +| `google_object_id` | String | Google Wallet object ID | +| `google_object_jwt` | Text | Google Wallet JWT | +| `apple_serial_number` | String | Apple Wallet serial number | +| `apple_auth_token` | String | Apple Wallet auth token | +| `last_stamp_at` | DateTime | Last stamp timestamp | +| `last_points_at` | DateTime | Last points timestamp | +| `last_redemption_at` | DateTime | Last redemption timestamp | +| `last_activity_at` | DateTime | Last activity of any kind | +| `is_active` | Boolean | Whether card is active | + +### LoyaltyTransaction + +Immutable audit log of all loyalty operations. Every stamp, point, redemption, and void is recorded. + +| Field | Type | Description | +|-------|------|-------------| +| `merchant_id` | FK | Merchant program owner | +| `card_id` | FK | Affected card | +| `store_id` | FK | Store where transaction occurred | +| `staff_pin_id` | FK (nullable) | Staff who verified | +| `related_transaction_id` | FK (nullable) | For void/return linking | +| `transaction_type` | Enum | See transaction types below | +| `stamps_delta` | Integer | Signed stamp change | +| `points_delta` | Integer | Signed points change | +| `stamps_balance_after` | Integer | Stamp count after transaction | +| `points_balance_after` | Integer | Points balance after transaction | +| `purchase_amount_cents` | Integer | Purchase amount for points earning | +| `order_reference` | String | External order reference | +| `reward_id` | String | Redeemed reward identifier | +| `reward_description` | String | Redeemed reward description | +| `ip_address` | String | Client IP (audit) | +| `user_agent` | String | Client user agent (audit) | +| `notes` | Text | Staff/admin notes | +| `transaction_at` | DateTime | When transaction occurred | + +**Transaction Types:** + +| Type | Category | Description | +|------|----------|-------------| +| `STAMP_EARNED` | Stamps | Customer earned a stamp | +| `STAMP_REDEEMED` | Stamps | Stamps exchanged for reward | +| `STAMP_VOIDED` | Stamps | Stamp reversed (return) | +| `STAMP_ADJUSTMENT` | Stamps | Manual adjustment | +| `POINTS_EARNED` | Points | Points from purchase | +| `POINTS_REDEEMED` | Points | Points exchanged for reward | +| `POINTS_VOIDED` | Points | Points reversed (return) | +| `POINTS_ADJUSTMENT` | Points | Manual adjustment | +| `POINTS_EXPIRED` | Points | Points expired due to inactivity | +| `CARD_CREATED` | Lifecycle | Card enrollment | +| `CARD_DEACTIVATED` | Lifecycle | Card deactivated | +| `WELCOME_BONUS` | Bonus | Welcome bonus points on enrollment | + +### StaffPin + +Staff authentication PINs for fraud prevention. Scoped to a store within a merchant's program. + +| Field | Type | Description | +|-------|------|-------------| +| `merchant_id` | FK | Merchant | +| `program_id` | FK | Associated program | +| `store_id` | FK | Store this PIN is for | +| `name` | String | Staff member name | +| `staff_id` | String | External staff identifier | +| `pin_hash` | String | Bcrypt-hashed PIN | +| `failed_attempts` | Integer | Consecutive failed attempts | +| `locked_until` | DateTime | Lockout expiry (nullable) | +| `last_used_at` | DateTime | Last successful use | +| `is_active` | Boolean | Whether PIN is active | + +### MerchantLoyaltySettings + +Admin-controlled settings for a merchant's loyalty program. Separate from program config to allow admin overrides. + +| Field | Type | Description | +|-------|------|-------------| +| `merchant_id` | FK (unique) | One settings record per merchant | +| `staff_pin_policy` | Enum | REQUIRED, OPTIONAL, or DISABLED | +| `staff_pin_lockout_attempts` | Integer | Failed attempts before lockout | +| `staff_pin_lockout_minutes` | Integer | Lockout duration | +| `allow_self_enrollment` | Boolean | Whether customers can self-enroll | +| `allow_void_transactions` | Boolean | Whether voids are allowed | +| `allow_cross_location_redemption` | Boolean | Cross-store redemption | +| `require_order_reference` | Boolean | Require order ref for points | +| `log_ip_addresses` | Boolean | Log IPs in transactions | + +### AppleDeviceRegistration + +Tracks Apple devices registered for wallet push notifications when card balances change. + +| Field | Type | Description | +|-------|------|-------------| +| `card_id` | FK | Associated loyalty card | +| `device_library_identifier` | String | Apple device identifier | +| `push_token` | String | APNs push token | + +Unique constraint on `(device_library_identifier, card_id)`. diff --git a/app/modules/loyalty/docs/index.md b/app/modules/loyalty/docs/index.md new file mode 100644 index 00000000..fb1ab80c --- /dev/null +++ b/app/modules/loyalty/docs/index.md @@ -0,0 +1,110 @@ +# Loyalty Programs + +Stamp-based and points-based loyalty programs with Google Wallet and Apple Wallet integration. Includes anti-fraud features like staff PINs, cooldown periods, and daily limits. + +## Overview + +| Aspect | Detail | +|--------|--------| +| Code | `loyalty` | +| Classification | Optional | +| Dependencies | `customers` | +| Status | Active | + +## Features + +- `loyalty_stamps` — Stamp-based loyalty (collect N, get reward) +- `loyalty_points` — Points-based loyalty (earn per euro spent) +- `loyalty_hybrid` — Combined stamps and points programs +- `loyalty_cards` — Digital loyalty card management +- `loyalty_enrollment` — Customer enrollment flow +- `loyalty_staff_pins` — Staff PIN verification +- `loyalty_anti_fraud` — Cooldown periods, daily limits, lockout protection +- `loyalty_google_wallet` — Google Wallet pass integration +- `loyalty_apple_wallet` — Apple Wallet pass integration +- `loyalty_stats` — Program statistics +- `loyalty_reports` — Loyalty reporting + +## Permissions + +| Permission | Description | +|------------|-------------| +| `loyalty.view_programs` | View loyalty programs | +| `loyalty.manage_programs` | Create/edit loyalty programs | +| `loyalty.view_rewards` | View rewards | +| `loyalty.manage_rewards` | Manage rewards | + +## Data Model + +See [Data Model](data-model.md) for full entity relationships. + +- **LoyaltyProgram** — Program configuration (type, targets, branding) +- **LoyaltyCard** — Customer cards with stamp/point balances +- **LoyaltyTransaction** — Immutable audit log of all operations +- **StaffPin** — Hashed PINs for fraud prevention +- **MerchantLoyaltySettings** — Admin-controlled merchant settings +- **AppleDeviceRegistration** — Apple Wallet push notification tokens + +## API Endpoints + +### Store Endpoints (`/api/v1/store/loyalty/`) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/program` | Get store's loyalty program | +| `POST` | `/program` | Create loyalty program | +| `PATCH` | `/program` | Update loyalty program | +| `GET` | `/stats` | Get program statistics | +| `GET` | `/cards` | List customer cards | +| `POST` | `/cards/enroll` | Enroll customer in program | +| `POST` | `/stamp` | Add stamp to card | +| `POST` | `/stamp/redeem` | Redeem stamps for reward | +| `POST` | `/points` | Earn points from purchase | +| `POST` | `/points/redeem` | Redeem points for reward | +| `*` | `/pins/*` | Staff PIN management | + +### Admin Endpoints (`/api/v1/admin/loyalty/`) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/programs` | List all loyalty programs | +| `GET` | `/programs/{id}` | Get specific program | +| `GET` | `/stats` | Platform-wide statistics | + +### Storefront Endpoints (`/api/v1/storefront/loyalty/`) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/card` | Get customer's loyalty card | +| `GET` | `/transactions` | Transaction history | +| `POST` | `/enroll` | Self-enrollment | + +## Scheduled Tasks + +| Task | Schedule | Description | +|------|----------|-------------| +| `loyalty.sync_wallet_passes` | Hourly | Sync cards that missed real-time updates | +| `loyalty.expire_points` | Daily 02:00 | Expire points for inactive cards | + +## Configuration + +Environment variables (prefix: `LOYALTY_`): + +| Variable | Default | Description | +|----------|---------|-------------| +| `LOYALTY_DEFAULT_COOLDOWN_MINUTES` | 15 | Cooldown between stamps | +| `LOYALTY_MAX_DAILY_STAMPS` | 5 | Max stamps per card per day | +| `LOYALTY_PIN_MAX_FAILED_ATTEMPTS` | 5 | PIN lockout threshold | +| `LOYALTY_PIN_LOCKOUT_MINUTES` | 30 | PIN lockout duration | +| `LOYALTY_DEFAULT_POINTS_PER_EURO` | 10 | Points earned per euro | +| `LOYALTY_GOOGLE_ISSUER_ID` | — | Google Wallet issuer ID | +| `LOYALTY_GOOGLE_SERVICE_ACCOUNT_JSON` | — | Google service account path | +| `LOYALTY_APPLE_*` | — | Apple Wallet certificate paths | + +## Additional Documentation + +- [Data Model](data-model.md) — Entity relationships and database schema +- [Business Logic](business-logic.md) — Anti-fraud system, wallet integration, enrollment flow +- [User Journeys](user-journeys.md) — Detailed user journey flows with dev/prod URLs +- [Program Analysis](program-analysis.md) — Business analysis and platform vision +- [UI Design](ui-design.md) — Admin and store interface mockups and implementation roadmap diff --git a/app/modules/loyalty/docs/program-analysis.md b/app/modules/loyalty/docs/program-analysis.md new file mode 100644 index 00000000..10506bed --- /dev/null +++ b/app/modules/loyalty/docs/program-analysis.md @@ -0,0 +1,387 @@ +# Loyalty Program Platform - Business Analysis + +**Session Date:** 2026-01-13 +**Status:** Initial Analysis - Pending Discussion +**Next Steps:** Resume discussion to clarify requirements + +--- + +## Executive Summary + +Multiple retailers have expressed interest in a loyalty program application. This document analyzes how the current OMS platform could be leveraged to provide a loyalty program offering as a new product line. + +--- + +## Business Proposal Overview + +### Concept +- **Multi-platform offering**: Different platform tiers (A, B, C) with varying feature sets +- **Target clients**: Merchants (retailers) with one or multiple shops +- **Core functionality**: + - Customer email collection + - Promotions and campaigns + - Discounts and rewards + - Points accumulation + +### Platform Architecture Vision + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ PLATFORM LEVEL │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Platform A │ │ Platform B │ │ Platform C │ ... │ +│ │ (Loyalty+) │ │ (Basic) │ │ (Enterprise) │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ CLIENT LEVEL (Merchant) │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Retailer X (e.g., Bakery Chain) │ │ +│ │ ├── Shop 1 (Luxembourg City) │ │ +│ │ ├── Shop 2 (Esch) │ │ +│ │ └── Shop 3 (Differdange) │ │ +│ └──────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ CUSTOMER LEVEL │ +│ • Email collection • Points accumulation │ +│ • Promotions/Offers • Discounts/Rewards │ +│ • Purchase history • Tier status │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Current OMS Architecture Leverage + +The existing platform has several components that map directly to loyalty program needs: + +| Current OMS Component | Loyalty Program Use | +|-----------------------|---------------------| +| `Merchant` model | Client (retailer chain) | +| `Store` model | Individual shop/location | +| `Customer` model | Loyalty member base | +| `Order` model | Transaction for points calculation | +| `User` (store role) | Shop staff for check-in/redemption | +| Multi-tenant auth | Per-client data isolation | +| Admin dashboard | Retailer management interface | +| Store dashboard | Shop-level operations | +| API infrastructure | Integration capabilities | + +### Existing Infrastructure Benefits +- Authentication & authorization system +- Multi-tenant data isolation +- Merchant → Store hierarchy +- Customer management +- Email/notification system (if exists) +- Celery background tasks +- API patterns established + +--- + +## New Components Required + +### 1. Core Loyalty Models + +```python +# New database models needed + +LoyaltyProgram + - id + - merchant_id (FK) + - name + - points_per_euro (Decimal) + - points_expiry_days (Integer, nullable) + - is_active (Boolean) + - settings (JSON) - flexible configuration + +LoyaltyMember + - id + - customer_id (FK to existing Customer) + - loyalty_program_id (FK) + - points_balance (Integer) + - lifetime_points (Integer) + - tier_id (FK) + - enrolled_at (DateTime) + - last_activity_at (DateTime) + +LoyaltyTier + - id + - loyalty_program_id (FK) + - name (e.g., "Bronze", "Silver", "Gold") + - min_points_required (Integer) + - benefits (JSON) + - sort_order (Integer) + +LoyaltyTransaction + - id + - member_id (FK) + - store_id (FK) - which shop + - transaction_type (ENUM: earn, redeem, expire, adjust) + - points (Integer, positive or negative) + - reference_type (e.g., "order", "promotion", "manual") + - reference_id (Integer, nullable) + - description (String) + - created_at (DateTime) + - created_by_user_id (FK, nullable) + +Promotion + - id + - loyalty_program_id (FK) + - name + - description + - promotion_type (ENUM: bonus_points, discount_percent, discount_fixed, free_item) + - value (Decimal) + - conditions (JSON) - min spend, specific products, etc. + - start_date (DateTime) + - end_date (DateTime) + - max_redemptions (Integer, nullable) + - is_active (Boolean) + +PromotionRedemption + - id + - promotion_id (FK) + - member_id (FK) + - store_id (FK) + - redeemed_at (DateTime) + - order_id (FK, nullable) + +Reward + - id + - loyalty_program_id (FK) + - name + - description + - points_cost (Integer) + - reward_type (ENUM: discount, free_product, voucher) + - value (Decimal or JSON) + - is_active (Boolean) + - stock (Integer, nullable) - for limited rewards +``` + +### 2. Platform Offering Tiers + +```python +# Platform-level configuration + +class PlatformOffering(Enum): + BASIC = "basic" + PLUS = "plus" + ENTERPRISE = "enterprise" + +# Feature matrix per offering +OFFERING_FEATURES = { + "basic": { + "max_shops": 1, + "points_earning": True, + "basic_promotions": True, + "tiers": False, + "custom_rewards": False, + "api_access": False, + "white_label": False, + "analytics": "basic", + }, + "plus": { + "max_shops": 10, + "points_earning": True, + "basic_promotions": True, + "tiers": True, + "custom_rewards": True, + "api_access": False, + "white_label": False, + "analytics": "advanced", + }, + "enterprise": { + "max_shops": None, # Unlimited + "points_earning": True, + "basic_promotions": True, + "tiers": True, + "custom_rewards": True, + "api_access": True, + "white_label": True, + "analytics": "full", + }, +} +``` + +### 3. Feature Matrix + +| Feature | Basic | Plus | Enterprise | +|---------|:-----:|:----:|:----------:| +| Customer email collection | ✓ | ✓ | ✓ | +| Points earning | ✓ | ✓ | ✓ | +| Basic promotions | ✓ | ✓ | ✓ | +| Multi-shop support | 1 shop | Up to 10 | Unlimited | +| Tier system (Bronze/Silver/Gold) | - | ✓ | ✓ | +| Custom rewards catalog | - | ✓ | ✓ | +| API access | - | - | ✓ | +| White-label branding | - | - | ✓ | +| Analytics dashboard | Basic | Advanced | Full | +| Customer segmentation | - | ✓ | ✓ | +| Email campaigns | - | ✓ | ✓ | +| Dedicated support | - | - | ✓ | + +--- + +## Implementation Options + +### Option A: Standalone Application +- Separate codebase +- Shares database patterns but independent deployment +- **Pros**: Clean separation, can scale independently +- **Cons**: Duplication of auth, admin patterns; more maintenance + +### Option B: Module in Current OMS (Recommended) +- Add loyalty as a feature module within existing platform +- Leverages existing infrastructure + +**Proposed directory structure:** +``` +letzshop-product-import/ +├── app/ +│ ├── api/v1/ +│ │ ├── loyalty/ # NEW +│ │ │ ├── __init__.py +│ │ │ ├── programs.py # Program CRUD +│ │ │ ├── members.py # Member management +│ │ │ ├── transactions.py # Points transactions +│ │ │ ├── promotions.py # Promotion management +│ │ │ ├── rewards.py # Rewards catalog +│ │ │ └── public.py # Customer-facing endpoints +│ │ │ +│ ├── services/ +│ │ ├── loyalty/ # NEW +│ │ │ ├── __init__.py +│ │ │ ├── points_service.py # Points calculation logic +│ │ │ ├── tier_service.py # Tier management +│ │ │ ├── promotion_service.py # Promotion rules engine +│ │ │ └── reward_service.py # Reward redemption +│ │ │ +│ ├── templates/ +│ │ ├── loyalty/ # NEW - if web UI needed +│ │ │ ├── admin/ # Platform admin views +│ │ │ ├── retailer/ # Retailer dashboard +│ │ │ └── member/ # Customer-facing portal +│ │ │ +├── models/ +│ ├── database/ +│ │ ├── loyalty.py # NEW - All loyalty models +│ ├── schema/ +│ │ ├── loyalty.py # NEW - Pydantic schemas +``` + +--- + +## Open Questions (To Discuss) + +### 1. Points Model +- **Q1.1**: Fixed points per euro spent? (e.g., 1 point = €0.10 spent) +- **Q1.2**: Variable points by product category? (e.g., 2x points on bakery items) +- **Q1.3**: Bonus points for specific actions? (e.g., sign-up bonus, birthday bonus) +- **Q1.4**: Points expiration policy? (e.g., expire after 12 months of inactivity) + +### 2. Redemption Methods +- **Q2.1**: In-store redemption only? (requires POS integration or staff app) +- **Q2.2**: Online shop redemption? +- **Q2.3**: Both in-store and online? +- **Q2.4**: What POS systems do target retailers use? + +### 3. Customer Identification +- **Q3.1**: Email only? +- **Q3.2**: Phone number as alternative? +- **Q3.3**: Physical loyalty card with barcode/QR? +- **Q3.4**: Mobile app with digital card? +- **Q3.5**: Integration with existing customer accounts? + +### 4. Multi-Platform Architecture +- **Q4.1**: Different domains per offering tier? + - e.g., loyalty-basic.lu, loyalty-pro.lu, loyalty-enterprise.lu +- **Q4.2**: Same domain with feature flags based on subscription? +- **Q4.3**: White-label with custom domains for enterprise clients? + +### 5. Data & Privacy +- **Q5.1**: Can retailers see each other's customers? (Assumed: No) +- **Q5.2**: Can a customer be enrolled in multiple loyalty programs? (Different retailers) +- **Q5.3**: GDPR considerations for customer data? +- **Q5.4**: Data export/portability requirements? + +### 6. Business Model +- **Q6.1**: Pricing model? (Monthly subscription, per-transaction fee, hybrid?) +- **Q6.2**: Free trial period? +- **Q6.3**: Upgrade/downgrade path between tiers? + +### 7. Integration Requirements +- **Q7.1**: POS system integrations needed? +- **Q7.2**: Email marketing platform integration? (Mailchimp, SendGrid, etc.) +- **Q7.3**: SMS notifications? +- **Q7.4**: Accounting/invoicing integration? + +### 8. MVP Scope +- **Q8.1**: What is the minimum viable feature set for first launch? +- **Q8.2**: Which offering tier to build first? +- **Q8.3**: Target timeline? +- **Q8.4**: Pilot retailers identified? + +--- + +## Potential User Flows + +### Retailer Onboarding Flow +1. Retailer signs up on platform +2. Selects offering tier (Basic/Plus/Enterprise) +3. Configures loyalty program (name, points ratio, branding) +4. Adds shop locations +5. Invites staff members +6. Sets up initial promotions +7. Goes live + +### Customer Enrollment Flow +1. Customer visits shop or website +2. Provides email (and optionally phone) +3. Receives welcome email with member ID/card +4. Starts earning points on purchases + +### Points Earning Flow (In-Store) +1. Customer makes purchase +2. Staff asks for loyalty member ID (email, phone, or card scan) +3. System calculates points based on purchase amount +4. Points credited to member account +5. Receipt shows points earned and balance + +### Reward Redemption Flow +1. Customer views available rewards (app/web/in-store) +2. Selects reward to redeem +3. System validates sufficient points +4. Generates redemption code/voucher +5. Customer uses at checkout +6. Points deducted from balance + +--- + +## Next Steps + +1. **Clarify requirements** - Answer open questions above +2. **Define MVP scope** - What's the minimum for first launch? +3. **Technical design** - Database schema, API design +4. **UI/UX design** - Retailer dashboard, customer portal +5. **Implementation plan** - Phased approach +6. **Pilot program** - Identify first retailers for beta + +--- + +## Session Notes + +### 2026-01-13 +- Initial business proposal discussion +- Analyzed current OMS architecture fit +- Identified reusable components +- Outlined new models needed +- Documented open questions +- **Action**: Resume discussion to clarify requirements + +--- + +*Document created for session continuity. Update as discussions progress.* diff --git a/app/modules/loyalty/docs/ui-design.md b/app/modules/loyalty/docs/ui-design.md new file mode 100644 index 00000000..4da2df90 --- /dev/null +++ b/app/modules/loyalty/docs/ui-design.md @@ -0,0 +1,670 @@ +# Loyalty Module Phase 2: Admin & Store Interfaces + +## Executive Summary + +This document outlines the plan for building admin and store interfaces for the Loyalty Module, along with detailed user journeys for stamp-based and points-based loyalty programs. The design follows market best practices from leading loyalty platforms (Square Loyalty, Toast, Fivestars, Belly, Punchh). + +--- + +## Part 1: Interface Design + +### 1.1 Store Dashboard (Retail Store) + +#### Main Loyalty Dashboard (`/store/loyalty`) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 🎯 Loyalty Program [Setup] │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────┐│ +│ │ 1,247 │ │ 892 │ │ 156 │ │ €2.3k ││ +│ │ Members │ │ Active │ │ Redeemed │ │ Saved ││ +│ │ Total │ │ 30 days │ │ This Month │ │ Value ││ +│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────┘│ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ 📊 Activity Chart (Last 30 Days) ││ +│ │ [Stamps Issued] [Rewards Redeemed] [New Members] ││ +│ │ ═══════════════════════════════════════════════ ││ +│ └─────────────────────────────────────────────────────────────┘│ +│ │ +│ ┌─────────────────────────┐ ┌─────────────────────────────┐ │ +│ │ 🔥 Quick Actions │ │ 📋 Recent Activity │ │ +│ │ │ │ │ │ +│ │ [➕ Add Stamp] │ │ • John D. earned stamp #8 │ │ +│ │ [🎁 Redeem Reward] │ │ • Marie L. redeemed reward │ │ +│ │ [👤 Enroll Customer] │ │ • Alex K. joined program │ │ +│ │ [🔍 Look Up Card] │ │ • Sarah M. earned 50 pts │ │ +│ │ │ │ │ │ +│ └─────────────────────────┘ └─────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +#### Stamp/Points Terminal (`/store/loyalty/terminal`) + +**Primary interface for daily operations - optimized for tablet/touchscreen:** + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 🎯 Loyalty Terminal │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌───────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ 📷 SCAN QR CODE │ │ +│ │ │ │ +│ │ [Camera Viewfinder Area] │ │ +│ │ │ │ +│ │ or enter card number │ │ +│ │ ┌─────────────────────────┐ │ │ +│ │ │ Card Number... │ │ │ +│ │ └─────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────┘ │ +│ │ +│ [Use Camera] [Enter Manually] [Recent Cards ▼] │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**After scanning - Customer Card View:** + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ ← Back Customer Card │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ 👤 Marie Laurent │ │ +│ │ marie.laurent@email.com │ │ +│ │ Member since: Jan 2024 │ │ +│ └─────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ ☕ ☕ ☕ ☕ ☕ ☕ ☕ ☕ ○ ○ │ │ +│ │ │ │ +│ │ 8 / 10 stamps │ │ +│ │ 2 more until FREE COFFEE │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ [ ➕ ADD STAMP ] [ 🎁 REDEEM ] │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────┘ │ +│ │ +│ ⚠️ Next stamp available in 12 minutes │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**PIN Entry Modal (appears when adding stamp):** + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Enter Staff PIN │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────┐ │ +│ │ ● ● ● ● │ │ +│ └─────────────────┘ │ +│ │ +│ ┌─────┐ ┌─────┐ ┌─────┐ │ +│ │ 1 │ │ 2 │ │ 3 │ │ +│ └─────┘ └─────┘ └─────┘ │ +│ ┌─────┐ ┌─────┐ ┌─────┐ │ +│ │ 4 │ │ 5 │ │ 6 │ │ +│ └─────┘ └─────┘ └─────┘ │ +│ ┌─────┐ ┌─────┐ ┌─────┐ │ +│ │ 7 │ │ 8 │ │ 9 │ │ +│ └─────┘ └─────┘ └─────┘ │ +│ ┌─────┐ ┌─────┐ ┌─────┐ │ +│ │ ⌫ │ │ 0 │ │ ✓ │ │ +│ └─────┘ └─────┘ └─────┘ │ +│ │ +│ [Cancel] │ +└─────────────────────────────────────────────────────────────────┘ +``` + +#### Program Setup (`/store/loyalty/settings`) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ ⚙️ Loyalty Program Settings │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Program Type │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ ☑️ Stamps │ │ ☐ Points │ │ ☐ Hybrid │ │ +│ │ Buy 10 Get 1 │ │ Earn per € │ │ Both systems │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ +│ │ +│ ───────────────────────────────────────────────────────────── │ +│ │ +│ Stamp Configuration │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ Stamps needed for reward: [ 10 ▼ ] │ │ +│ │ Reward description: [ Free coffee of choice ] │ │ +│ │ Reward value (optional): [ €4.50 ] │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ───────────────────────────────────────────────────────────── │ +│ │ +│ 🛡️ Fraud Prevention │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ ☑️ Require staff PIN for operations │ │ +│ │ Cooldown between stamps: [ 15 ] minutes │ │ +│ │ Max stamps per day: [ 5 ] │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ───────────────────────────────────────────────────────────── │ +│ │ +│ 🎨 Card Branding │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ Card name: [ Café Loyalty Card ] │ │ +│ │ Primary color: [████] #4F46E5 │ │ +│ │ Logo: [Upload] cafe-logo.png ✓ │ │ +│ │ │ │ +│ │ Preview: ┌────────────────────┐ │ │ +│ │ │ ☕ Café Loyalty │ │ │ +│ │ │ ████████░░ │ │ │ +│ │ │ 8/10 stamps │ │ │ +│ │ └────────────────────┘ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ [Cancel] [Save Changes] │ +└─────────────────────────────────────────────────────────────────┘ +``` + +#### Staff PIN Management (`/store/loyalty/pins`) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 🔐 Staff PINs [+ Add PIN] │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 👤 Marie (Manager) [Edit] [🗑️] │ │ +│ │ Last used: Today, 14:32 │ │ +│ │ Status: ✅ Active │ │ +│ ├─────────────────────────────────────────────────────────┤ │ +│ │ 👤 Thomas (Staff) [Edit] [🗑️] │ │ +│ │ Last used: Today, 11:15 │ │ +│ │ Status: ✅ Active │ │ +│ ├─────────────────────────────────────────────────────────┤ │ +│ │ 👤 Julie (Staff) [Edit] [🗑️] │ │ +│ │ Last used: Yesterday │ │ +│ │ Status: 🔒 Locked (3 failed attempts) [Unlock] │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ℹ️ Staff PINs prevent unauthorized stamp/point operations. │ +│ PINs are locked after 5 failed attempts for 30 minutes. │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +#### Customer Cards List (`/store/loyalty/cards`) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 👥 Loyalty Members 🔍 [Search...] [Export]│ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Filter: [All ▼] [Active ▼] [Has Reward Ready ▼] │ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ Customer │ Card # │ Stamps │ Last Visit │ ⋮ ││ +│ ├───────────────────┼──────────────┼────────┼────────────┼────┤│ +│ │ Marie Laurent │ 4821-7493 │ 8/10 ⭐│ Today │ ⋮ ││ +│ │ Jean Dupont │ 4821-2847 │ 10/10 🎁│ Yesterday │ ⋮ ││ +│ │ Sophie Martin │ 4821-9382 │ 3/10 │ 3 days ago │ ⋮ ││ +│ │ Pierre Bernard │ 4821-1029 │ 6/10 │ 1 week ago │ ⋮ ││ +│ │ ... │ ... │ ... │ ... │ ⋮ ││ +│ └─────────────────────────────────────────────────────────────┘│ +│ │ +│ Showing 1-20 of 1,247 members [← Prev] [1] [2] [Next →]│ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 1.2 Admin Dashboard (Platform) + +#### Platform Loyalty Overview (`/admin/loyalty`) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 🎯 Loyalty Programs Platform │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────┐│ +│ │ 47 │ │ 38 │ │ 12,847 │ │ €47k ││ +│ │ Programs │ │ Active │ │ Members │ │ Saved ││ +│ │ Total │ │ Programs │ │ Total │ │ Value ││ +│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────┘│ +│ │ +│ Programs by Type: │ +│ ═══════════════════════════════════════ │ +│ Stamps: ████████████████████ 32 (68%) │ +│ Points: ███████ 11 (23%) │ +│ Hybrid: ████ 4 (9%) │ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ Store │ Type │ Members │ Activity │ Status ││ +│ ├───────────────────┼─────────┼─────────┼──────────┼──────────┤│ +│ │ Café du Coin │ Stamps │ 1,247 │ High │ ✅ Active││ +│ │ Boulangerie Paul │ Points │ 892 │ Medium │ ✅ Active││ +│ │ Pizza Roma │ Stamps │ 456 │ Low │ ⚠️ Setup ││ +│ │ ... │ ... │ ... │ ... │ ... ││ +│ └─────────────────────────────────────────────────────────────┘│ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Part 2: User Journeys + +### 2.1 Stamp-Based Loyalty Journey + +#### Customer Journey: Enrollment + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ STAMP LOYALTY - ENROLLMENT │ +└─────────────────────────────────────────────────────────────────┘ + + ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ + │ DISCOVER│────▶│ JOIN │────▶│ SAVE │────▶│ USE │ + └─────────┘ └─────────┘ └─────────┘ └─────────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ + ┌─────────────────────────────────────────────────────────┐ + │ 1. Customer sees │ 2. Scans QR at │ 3. Card added │ 4. Ready to │ + │ sign at counter│ register or │ to Google/ │ collect │ + │ "Join our │ gives email │ Apple Wallet│ stamps! │ + │ loyalty!" │ to cashier │ │ │ + └─────────────────────────────────────────────────────────┘ +``` + +**Detailed Steps:** + +1. **Discovery** (In-Store) + - Customer sees loyalty program signage/tent card + - QR code displayed at counter + - Staff mentions program during checkout + +2. **Sign Up** (30 seconds) + - Customer scans QR code with phone + - Lands on mobile enrollment page + - Enters: Email (required), Name (optional) + - Accepts terms with checkbox + - Submits + +3. **Card Creation** (Instant) + - System creates loyalty card + - Generates unique card number & QR code + - Shows "Add to Wallet" buttons + - Sends welcome email with card link + +4. **Wallet Save** (Optional but encouraged) + - Customer taps "Add to Google Wallet" or "Add to Apple Wallet" + - Pass appears in their wallet app + - Always accessible, works offline + +#### Customer Journey: Earning Stamps + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ STAMP LOYALTY - EARNING │ +└─────────────────────────────────────────────────────────────────┘ + + Customer Staff System Wallet + │ │ │ │ + │ 1. Makes │ │ │ + │ purchase │ │ │ + │───────────────▶│ │ │ + │ │ │ │ + │ 2. Shows │ │ │ + │ loyalty card │ │ │ + │───────────────▶│ │ │ + │ │ 3. Scans QR │ │ + │ │─────────────────▶│ │ + │ │ │ │ + │ │ 4. Enters PIN │ │ + │ │─────────────────▶│ │ + │ │ │ │ + │ │ 5. Confirms │ │ + │ │◀─────────────────│ │ + │ │ "Stamp added!" │ │ + │ │ │ │ + │ 6. Verbal │ │ 7. Push │ + │ confirmation │ │ notification │ + │◀───────────────│ │────────────────▶│ + │ │ │ │ + │ │ 8. Pass updates│ + │◀───────────────────────────────────│────────────────▶│ + │ "8/10 stamps" │ │ +``` + +**Anti-Fraud Checks (Automatic):** + +1. ✅ Card is active +2. ✅ Program is active +3. ✅ Staff PIN is valid +4. ✅ Cooldown period elapsed (15 min since last stamp) +5. ✅ Daily limit not reached (max 5/day) + +**Success Response:** +```json +{ + "success": true, + "stamp_count": 8, + "stamps_target": 10, + "stamps_until_reward": 2, + "message": "2 more stamps until your free coffee!", + "next_stamp_available": "2024-01-28T15:30:00Z" +} +``` + +#### Customer Journey: Redeeming Reward + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ STAMP LOYALTY - REDEMPTION │ +└─────────────────────────────────────────────────────────────────┘ + + Customer Staff System + │ │ │ + │ 1. "I'd like │ │ + │ to redeem my │ │ + │ free coffee" │ │ + │───────────────▶│ │ + │ │ │ + │ 2. Shows card │ │ + │ (10/10 stamps)│ │ + │───────────────▶│ │ + │ │ 3. Scans + sees │ + │ │ "REWARD READY" │ + │ │─────────────────▶│ + │ │ │ + │ │ 4. Clicks │ + │ │ [REDEEM REWARD] │ + │ │─────────────────▶│ + │ │ │ + │ │ 5. Enters PIN │ + │ │─────────────────▶│ + │ │ │ + │ │ 6. Confirms │ + │ │◀─────────────────│ + │ │ "Reward redeemed"│ + │ │ Stamps reset: 0 │ + │ │ │ + │ 7. Gives free │ │ + │ coffee │ │ + │◀───────────────│ │ + │ │ │ + │ 🎉 HAPPY │ │ + │ CUSTOMER! │ │ +``` + +### 2.2 Points-Based Loyalty Journey + +#### Customer Journey: Earning Points + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ POINTS LOYALTY - EARNING │ +└─────────────────────────────────────────────────────────────────┘ + + Customer Staff System + │ │ │ + │ 1. Purchases │ │ + │ €25.00 order │ │ + │───────────────▶│ │ + │ │ │ + │ 2. Shows │ │ + │ loyalty card │ │ + │───────────────▶│ │ + │ │ 3. Scans card │ + │ │─────────────────▶│ + │ │ │ + │ │ 4. Enters amount │ + │ │ €25.00 │ + │ │─────────────────▶│ + │ │ │ + │ │ 5. Enters PIN │ + │ │─────────────────▶│ + │ │ │ ┌──────────┐ + │ │ │ │Calculate:│ + │ │ │ │€25 × 10 │ + │ │ │ │= 250 pts │ + │ │ │ └──────────┘ + │ │ 6. Confirms │ + │ │◀─────────────────│ + │ │ "+250 points!" │ + │ │ │ + │ 7. Receipt │ │ + │ shows points │ │ + │◀───────────────│ │ +``` + +**Points Calculation:** +``` +Purchase: €25.00 +Rate: 10 points per euro +Points Earned: 250 points +New Balance: 750 points +``` + +#### Customer Journey: Redeeming Points + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ POINTS LOYALTY - REDEMPTION │ +└─────────────────────────────────────────────────────────────────┘ + + Customer Staff System + │ │ │ + │ 1. Views │ │ + │ rewards in │ │ + │ wallet app │ │ + │ │ │ │ + │ ▼ │ │ + │ ┌──────────┐ │ │ + │ │ REWARDS │ │ │ + │ │──────────│ │ │ + │ │ 500 pts │ │ │ + │ │ Free │ │ │ + │ │ Drink │ │ │ + │ │──────────│ │ │ + │ │ 1000 pts │ │ │ + │ │ Free │ │ │ + │ │ Meal │ │ │ + │ └──────────┘ │ │ + │ │ │ + │ 2. "I want to │ │ + │ redeem for │ │ + │ free drink" │ │ + │───────────────▶│ │ + │ │ 3. Scans card │ + │ │ Selects reward │ + │ │─────────────────▶│ + │ │ │ + │ │ 4. Enters PIN │ + │ │─────────────────▶│ + │ │ │ + │ │ 5. Confirms │ + │ │◀─────────────────│ + │ │ "-500 points" │ + │ │ Balance: 250 pts │ + │ │ │ + │ 6. Gets free │ │ + │ drink │ │ + │◀───────────────│ │ +``` + +--- + +## Part 3: Market Best Practices + +### 3.1 Competitive Analysis + +| Feature | Square Loyalty | Toast | Fivestars | **Orion** | +|---------|---------------|-------|-----------|--------------| +| Stamp cards | ✅ | ✅ | ✅ | ✅ | +| Points system | ✅ | ✅ | ✅ | ✅ | +| Google Wallet | ✅ | ❌ | ✅ | ✅ | +| Apple Wallet | ✅ | ✅ | ✅ | ✅ | +| Staff PIN | ❌ | ✅ | ✅ | ✅ | +| Cooldown fraud protection | ❌ | ❌ | ✅ | ✅ | +| Daily limits | ❌ | ❌ | ✅ | ✅ | +| Tablet terminal | ✅ | ✅ | ✅ | ✅ (planned) | +| Customer app | ✅ | ✅ | ✅ | Via Wallet | +| Analytics dashboard | ✅ | ✅ | ✅ | ✅ | + +### 3.2 Best Practices to Implement + +#### UX Best Practices + +1. **Instant gratification** - Show stamp/points immediately after transaction +2. **Progress visualization** - Clear progress bars/stamp grids +3. **Reward proximity** - "Only 2 more until your free coffee!" +4. **Wallet-first** - Push customers to save to wallet +5. **Offline support** - Card works even without internet (via wallet) + +#### Fraud Prevention Best Practices + +1. **Multi-layer security** - PIN + cooldown + daily limits +2. **Staff accountability** - Every transaction tied to a staff PIN +3. **Audit trail** - Complete history with IP/device info +4. **Lockout protection** - Automatic PIN lockout after failures +5. **Admin oversight** - Unlock and PIN management in dashboard + +#### Engagement Best Practices + +1. **Welcome bonus** - Give 1 stamp on enrollment (configurable) +2. **Birthday rewards** - Extra stamps/points on customer birthday +3. **Milestone notifications** - "Congrats! 50 stamps earned lifetime!" +4. **Re-engagement** - Remind inactive customers via email +5. **Double points days** - Promotional multipliers (future) + +--- + +## Part 4: Implementation Roadmap + +### Phase 2A: Store Interface (Priority) + +| Task | Effort | Priority | +|------|--------|----------| +| Loyalty terminal (scan/stamp/redeem) | 3 days | P0 | +| Program setup wizard | 2 days | P0 | +| Staff PIN management | 1 day | P0 | +| Customer cards list | 1 day | P1 | +| Dashboard with stats | 2 days | P1 | +| Export functionality | 1 day | P2 | + +### Phase 2B: Admin Interface + +| Task | Effort | Priority | +|------|--------|----------| +| Programs list view | 1 day | P1 | +| Platform-wide stats | 1 day | P1 | +| Program detail view | 0.5 day | P2 | + +### Phase 2C: Customer Experience + +| Task | Effort | Priority | +|------|--------|----------| +| Enrollment page (mobile) | 1 day | P0 | +| Card detail page | 0.5 day | P1 | +| Wallet pass polish | 1 day | P1 | +| Email templates | 1 day | P2 | + +### Phase 2D: Polish & Advanced + +| Task | Effort | Priority | +|------|--------|----------| +| QR code scanner (JS) | 2 days | P0 | +| Real-time updates (WebSocket) | 1 day | P2 | +| Receipt printing | 1 day | P3 | +| POS integration hooks | 2 days | P3 | + +--- + +## Part 5: Technical Specifications + +### Store Terminal Requirements + +- **Responsive**: Works on tablet (primary), desktop, mobile +- **Touch-friendly**: Large buttons, numpad for PIN +- **Camera access**: For QR code scanning (WebRTC) +- **Offline-capable**: Queue operations if network down (future) +- **Real-time**: WebSocket for instant updates + +### Frontend Stack + +- **Framework**: React/Vue components (match existing stack) +- **QR Scanner**: `html5-qrcode` or `@aspect-sdk/barcode-reader` +- **Charts**: Existing charting library (Chart.js or similar) +- **Animations**: CSS transitions for stamp animations + +### API Considerations + +- All store endpoints require `store_id` from auth token +- Staff PIN passed in request body, not headers +- Rate limiting on lookup/scan endpoints +- Pagination on card list (default 50) + +--- + +## Appendix: Mockup Reference Images + +### Stamp Card Visual (Wallet Pass) + +``` +┌────────────────────────────────────┐ +│ ☕ Café du Coin │ +│ │ +│ ████ ████ ████ ████ ████ │ +│ ████ ████ ████ ░░░░ ░░░░ │ +│ │ +│ 8/10 STAMPS │ +│ 2 more until FREE COFFEE │ +│ │ +│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ +│ ▓▓▓▓▓▓▓▓ QR CODE ▓▓▓▓▓▓▓▓ │ +│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ +│ │ +│ Card #4821-7493-2841 │ +└────────────────────────────────────┘ +``` + +### Points Card Visual (Wallet Pass) + +``` +┌────────────────────────────────────┐ +│ 🍕 Pizza Roma Rewards │ +│ │ +│ ★ 750 ★ │ +│ POINTS │ +│ │ +│ ────────────────────── │ +│ Next reward: 500 pts │ +│ Free drink │ +│ ────────────────────── │ +│ │ +│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ +│ ▓▓▓▓▓▓▓▓ QR CODE ▓▓▓▓▓▓▓▓ │ +│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ +│ │ +│ Card #4821-2847-9283 │ +└────────────────────────────────────┘ +``` + +--- + +*Document Version: 1.0* +*Created: 2025-01-28* +*Author: Orion Engineering* diff --git a/app/modules/loyalty/docs/user-journeys.md b/app/modules/loyalty/docs/user-journeys.md new file mode 100644 index 00000000..4dd75478 --- /dev/null +++ b/app/modules/loyalty/docs/user-journeys.md @@ -0,0 +1,794 @@ +# Loyalty Module - User Journeys + +## Personas + +| # | Persona | Role / Auth | Description | +|---|---------|-------------|-------------| +| 1 | **Platform Admin** | `admin` role | Oversees all merchants' loyalty programs, views platform-wide stats, manages merchant settings | +| 2 | **Merchant Owner** | `store` role + owns merchant | Manages their merchant-wide loyalty program via the store interface. There is **no separate merchant owner UI** - loyalty programs are merchant-scoped but managed through any of the merchant's stores | +| 3 | **Store Staff / Team Member** | `store` role + store membership | Operates the POS terminal - scans cards, adds stamps/points, redeems rewards | +| 4 | **Customer (authenticated)** | Customer login | Views their loyalty card, balance, and transaction history | +| 5 | **Customer (anonymous)** | No auth | Browses program info, self-enrolls, downloads wallet passes | + +!!! note "Merchant Owner vs Store Staff" + The loyalty module does **not** have a dedicated merchant owner interface. The merchant owner + accesses loyalty through the **store interface** (`/store/{store_code}/loyalty/...`). Since the + loyalty program is scoped at the merchant level (one program shared by all stores), the owner + can manage it from any of their stores. The difference is only in **permissions** - owners have + full access, team members have role-based access. + +--- + +## Current Dev Database State + +### Merchants & Stores + +| Merchant | Owner | Stores | +|----------|-------|--------| +| WizaCorp Ltd. (id=1) | john.owner@wizacorp.com | ORION, WIZAGADGETS, WIZAHOME | +| Fashion Group S.A. (id=2) | jane.owner@fashiongroup.com | FASHIONHUB, FASHIONOUTLET | +| BookWorld Publishing (id=3) | bob.owner@bookworld.com | BOOKSTORE, BOOKDIGITAL | + +### Users + +| Email | Role | Type | +|-------|------|------| +| admin@orion.lu | admin | Platform admin | +| samir.boulahtit@gmail.com | admin | Platform admin | +| john.owner@wizacorp.com | store | Owner of WizaCorp (merchant 1) | +| jane.owner@fashiongroup.com | store | Owner of Fashion Group (merchant 2) | +| bob.owner@bookworld.com | store | Owner of BookWorld (merchant 3) | +| alice.manager@wizacorp.com | store | Team member (stores 1, 2) | +| charlie.staff@wizacorp.com | store | Team member (store 3) | +| diana.stylist@fashiongroup.com | store | Team member (stores 4, 5) | +| eric.sales@fashiongroup.com | store | Team member (store 5) | +| fiona.editor@bookworld.com | store | Team member (stores 6, 7) | + +### Loyalty Data Status + +| Table | Rows | +|-------|------| +| loyalty_programs | 0 | +| loyalty_cards | 0 | +| loyalty_transactions | 0 | +| merchant_loyalty_settings | 0 | +| staff_pins | 0 | +| merchant_subscriptions | 0 | + +!!! warning "No loyalty programs exist yet" + All loyalty tables are empty. The first step in testing is to create a loyalty program + via the store interface. There are also **no subscriptions** set up, which may gate access + to the loyalty module depending on feature-gating configuration. + +--- + +## Dev URLs (localhost:9999) + +The dev server uses path-based platform routing: `http://localhost:9999/platforms/loyalty/...` + +### 1. Platform Admin Pages + +Login as: `admin@orion.lu` or `samir.boulahtit@gmail.com` + +| Page | Dev URL | +|------|---------| +| Programs Dashboard | `http://localhost:9999/platforms/loyalty/admin/loyalty/programs` | +| Analytics | `http://localhost:9999/platforms/loyalty/admin/loyalty/analytics` | +| WizaCorp Detail | `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/1` | +| WizaCorp Settings | `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/1/settings` | +| Fashion Group Detail | `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/2` | +| Fashion Group Settings | `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/2/settings` | +| BookWorld Detail | `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/3` | +| BookWorld Settings | `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/3/settings` | + +### 2. Merchant Owner / Store Pages + +Login as the store owner, then navigate to any of their stores. + +**WizaCorp (john.owner@wizacorp.com):** + +| Page | Dev URL | +|------|---------| +| Terminal | `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/terminal` | +| Cards | `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/cards` | +| Settings | `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/settings` | +| Stats | `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/stats` | +| Enroll Customer | `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/enroll` | + +**Fashion Group (jane.owner@fashiongroup.com):** + +| Page | Dev URL | +|------|---------| +| Terminal | `http://localhost:9999/platforms/loyalty/store/FASHIONHUB/loyalty/terminal` | +| Cards | `http://localhost:9999/platforms/loyalty/store/FASHIONHUB/loyalty/cards` | +| Settings | `http://localhost:9999/platforms/loyalty/store/FASHIONHUB/loyalty/settings` | +| Stats | `http://localhost:9999/platforms/loyalty/store/FASHIONHUB/loyalty/stats` | +| Enroll Customer | `http://localhost:9999/platforms/loyalty/store/FASHIONHUB/loyalty/enroll` | + +**BookWorld (bob.owner@bookworld.com):** + +| Page | Dev URL | +|------|---------| +| Terminal | `http://localhost:9999/platforms/loyalty/store/BOOKSTORE/loyalty/terminal` | +| Cards | `http://localhost:9999/platforms/loyalty/store/BOOKSTORE/loyalty/cards` | +| Settings | `http://localhost:9999/platforms/loyalty/store/BOOKSTORE/loyalty/settings` | +| Stats | `http://localhost:9999/platforms/loyalty/store/BOOKSTORE/loyalty/stats` | +| Enroll Customer | `http://localhost:9999/platforms/loyalty/store/BOOKSTORE/loyalty/enroll` | + +### 3. Customer Storefront Pages + +Login as a customer (e.g., `customer1@orion.example.com`). + +!!! note "Store domain required" + Storefront pages require a store domain context. Only ORION (`orion.shop`) + and FASHIONHUB (`fashionhub.store`) have domains configured. In dev, storefront + routes may need to be accessed through the store's domain or platform path. + +| Page | Dev URL | +|------|---------| +| Loyalty Dashboard | `http://localhost:9999/platforms/loyalty/account/loyalty` | +| Transaction History | `http://localhost:9999/platforms/loyalty/account/loyalty/history` | + +### 4. Public Pages (No Auth) + +| Page | Dev URL | +|------|---------| +| Self-Enrollment | `http://localhost:9999/platforms/loyalty/loyalty/join` | +| Enrollment Success | `http://localhost:9999/platforms/loyalty/loyalty/join/success` | + +### 5. API Endpoints + +**Admin API** (prefix: `/platforms/loyalty/api/admin/loyalty/`): + +| Method | Dev URL | +|--------|---------| +| GET | `http://localhost:9999/platforms/loyalty/api/admin/loyalty/programs` | +| GET | `http://localhost:9999/platforms/loyalty/api/admin/loyalty/stats` | + +**Store API** (prefix: `/platforms/loyalty/api/store/loyalty/`): + +| Method | Endpoint | Dev URL | +|--------|----------|---------| +| GET | program | `http://localhost:9999/platforms/loyalty/api/store/loyalty/program` | +| POST | program | `http://localhost:9999/platforms/loyalty/api/store/loyalty/program` | +| POST | stamp | `http://localhost:9999/platforms/loyalty/api/store/loyalty/stamp` | +| POST | points | `http://localhost:9999/platforms/loyalty/api/store/loyalty/points` | +| POST | enroll | `http://localhost:9999/platforms/loyalty/api/store/loyalty/cards/enroll` | +| POST | lookup | `http://localhost:9999/platforms/loyalty/api/store/loyalty/cards/lookup` | + +**Storefront API** (prefix: `/platforms/loyalty/api/storefront/`): + +| Method | Endpoint | Dev URL | +|--------|----------|---------| +| GET | program | `http://localhost:9999/platforms/loyalty/api/storefront/loyalty/program` | +| POST | enroll | `http://localhost:9999/platforms/loyalty/api/storefront/loyalty/enroll` | +| GET | card | `http://localhost:9999/platforms/loyalty/api/storefront/loyalty/card` | +| GET | transactions | `http://localhost:9999/platforms/loyalty/api/storefront/loyalty/transactions` | + +**Public API** (prefix: `/platforms/loyalty/api/loyalty/`): + +| Method | Endpoint | Dev URL | +|--------|----------|---------| +| GET | program | `http://localhost:9999/platforms/loyalty/api/loyalty/programs/ORION` | + +--- + +## Production URLs (rewardflow.lu) + +In production, the platform uses **domain-based routing** instead of the `/platforms/loyalty/` path prefix. +Store context is detected via **custom domains** (registered in `store_domains` table) +or **subdomains** of `rewardflow.lu` (from `Store.subdomain`). + +### URL Routing Summary + +| Routing mode | Priority | Pattern | Example | +|-------------|----------|---------|---------| +| Platform domain | — | `rewardflow.lu/...` | Admin pages, public API | +| Store custom domain | 1 (highest) | `{custom_domain}/...` | Store with its own domain (overrides merchant domain) | +| Merchant domain | 2 | `{merchant_domain}/...` | All stores inherit merchant's domain | +| Store subdomain | 3 (fallback) | `{store_code}.rewardflow.lu/...` | Default when no custom/merchant domain | + +!!! info "Domain Resolution Priority" + When a request arrives, the middleware resolves the store in this order: + + 1. **Store custom domain** (`store_domains` table) — highest priority, store-specific override + 2. **Merchant domain** (`merchant_domains` table) — inherited by all merchant's stores + 3. **Store subdomain** (`Store.subdomain` + platform domain) — fallback + +### Case 1: Store with custom domain (e.g., `orion.shop`) + +The store has a verified entry in the `store_domains` table. **All** store URLs +(storefront, store backend, store APIs) are served from the custom domain. + +**Storefront (customer-facing):** + +| Page | Production URL | +|------|----------------| +| Loyalty Dashboard | `https://orion.shop/account/loyalty` | +| Transaction History | `https://orion.shop/account/loyalty/history` | +| Self-Enrollment | `https://orion.shop/loyalty/join` | +| Enrollment Success | `https://orion.shop/loyalty/join/success` | + +**Storefront API:** + +| Method | Production URL | +|--------|----------------| +| GET card | `https://orion.shop/api/storefront/loyalty/card` | +| GET transactions | `https://orion.shop/api/storefront/loyalty/transactions` | +| POST enroll | `https://orion.shop/api/storefront/loyalty/enroll` | +| GET program | `https://orion.shop/api/storefront/loyalty/program` | + +**Store backend (staff/owner):** + +| Page | Production URL | +|------|----------------| +| Store Login | `https://orion.shop/store/ORION/login` | +| Terminal | `https://orion.shop/store/ORION/loyalty/terminal` | +| Cards | `https://orion.shop/store/ORION/loyalty/cards` | +| Card Detail | `https://orion.shop/store/ORION/loyalty/cards/{card_id}` | +| Settings | `https://orion.shop/store/ORION/loyalty/settings` | +| Stats | `https://orion.shop/store/ORION/loyalty/stats` | +| Enroll Customer | `https://orion.shop/store/ORION/loyalty/enroll` | + +**Store API:** + +| Method | Production URL | +|--------|----------------| +| GET program | `https://orion.shop/api/store/loyalty/program` | +| POST program | `https://orion.shop/api/store/loyalty/program` | +| POST stamp | `https://orion.shop/api/store/loyalty/stamp` | +| POST points | `https://orion.shop/api/store/loyalty/points` | +| POST enroll | `https://orion.shop/api/store/loyalty/cards/enroll` | +| POST lookup | `https://orion.shop/api/store/loyalty/cards/lookup` | + +### Case 2: Store with merchant domain (e.g., `myloyaltyprogram.lu`) + +The merchant has registered a domain in the `merchant_domains` table. Stores without +their own custom domain inherit the merchant domain. The middleware resolves the +merchant domain to the merchant's first active store by default, or to a specific +store when the URL includes `/store/{store_code}/...`. + +**Storefront (customer-facing):** + +| Page | Production URL | +|------|----------------| +| Loyalty Dashboard | `https://myloyaltyprogram.lu/account/loyalty` | +| Transaction History | `https://myloyaltyprogram.lu/account/loyalty/history` | +| Self-Enrollment | `https://myloyaltyprogram.lu/loyalty/join` | +| Enrollment Success | `https://myloyaltyprogram.lu/loyalty/join/success` | + +**Storefront API:** + +| Method | Production URL | +|--------|----------------| +| GET card | `https://myloyaltyprogram.lu/api/storefront/loyalty/card` | +| GET transactions | `https://myloyaltyprogram.lu/api/storefront/loyalty/transactions` | +| POST enroll | `https://myloyaltyprogram.lu/api/storefront/loyalty/enroll` | +| GET program | `https://myloyaltyprogram.lu/api/storefront/loyalty/program` | + +**Store backend (staff/owner):** + +| Page | Production URL | +|------|----------------| +| Store Login | `https://myloyaltyprogram.lu/store/WIZAGADGETS/login` | +| Terminal | `https://myloyaltyprogram.lu/store/WIZAGADGETS/loyalty/terminal` | +| Cards | `https://myloyaltyprogram.lu/store/WIZAGADGETS/loyalty/cards` | +| Settings | `https://myloyaltyprogram.lu/store/WIZAGADGETS/loyalty/settings` | +| Stats | `https://myloyaltyprogram.lu/store/WIZAGADGETS/loyalty/stats` | + +**Store API:** + +| Method | Production URL | +|--------|----------------| +| GET program | `https://myloyaltyprogram.lu/api/store/loyalty/program` | +| POST stamp | `https://myloyaltyprogram.lu/api/store/loyalty/stamp` | +| POST points | `https://myloyaltyprogram.lu/api/store/loyalty/points` | +| POST enroll | `https://myloyaltyprogram.lu/api/store/loyalty/cards/enroll` | +| POST lookup | `https://myloyaltyprogram.lu/api/store/loyalty/cards/lookup` | + +!!! note "Merchant domain resolves to first active store" + When a customer visits `myloyaltyprogram.lu` without a `/store/{code}/...` path, + the middleware resolves to the merchant's **first active store** (ordered by ID). + This is ideal for storefront pages like `/loyalty/join` where the customer doesn't + need to know which specific store they're interacting with. + +### Case 3: Store without custom domain (uses platform subdomain) + +The store has no entry in `store_domains` and the merchant has no registered domain. +**All** store URLs are served via a subdomain of the platform domain: `{store_code}.rewardflow.lu`. + +**Storefront (customer-facing):** + +| Page | Production URL | +|------|----------------| +| Loyalty Dashboard | `https://bookstore.rewardflow.lu/account/loyalty` | +| Transaction History | `https://bookstore.rewardflow.lu/account/loyalty/history` | +| Self-Enrollment | `https://bookstore.rewardflow.lu/loyalty/join` | +| Enrollment Success | `https://bookstore.rewardflow.lu/loyalty/join/success` | + +**Storefront API:** + +| Method | Production URL | +|--------|----------------| +| GET card | `https://bookstore.rewardflow.lu/api/storefront/loyalty/card` | +| GET transactions | `https://bookstore.rewardflow.lu/api/storefront/loyalty/transactions` | +| POST enroll | `https://bookstore.rewardflow.lu/api/storefront/loyalty/enroll` | +| GET program | `https://bookstore.rewardflow.lu/api/storefront/loyalty/program` | + +**Store backend (staff/owner):** + +| Page | Production URL | +|------|----------------| +| Store Login | `https://bookstore.rewardflow.lu/store/BOOKSTORE/login` | +| Terminal | `https://bookstore.rewardflow.lu/store/BOOKSTORE/loyalty/terminal` | +| Cards | `https://bookstore.rewardflow.lu/store/BOOKSTORE/loyalty/cards` | +| Settings | `https://bookstore.rewardflow.lu/store/BOOKSTORE/loyalty/settings` | +| Stats | `https://bookstore.rewardflow.lu/store/BOOKSTORE/loyalty/stats` | + +**Store API:** + +| Method | Production URL | +|--------|----------------| +| GET program | `https://bookstore.rewardflow.lu/api/store/loyalty/program` | +| POST stamp | `https://bookstore.rewardflow.lu/api/store/loyalty/stamp` | +| POST points | `https://bookstore.rewardflow.lu/api/store/loyalty/points` | +| POST enroll | `https://bookstore.rewardflow.lu/api/store/loyalty/cards/enroll` | +| POST lookup | `https://bookstore.rewardflow.lu/api/store/loyalty/cards/lookup` | + +### Platform Admin & Public API (always on platform domain) + +| Page / Endpoint | Production URL | +|-----------------|----------------| +| Admin Programs | `https://rewardflow.lu/admin/loyalty/programs` | +| Admin Analytics | `https://rewardflow.lu/admin/loyalty/analytics` | +| Admin Merchant Detail | `https://rewardflow.lu/admin/loyalty/merchants/{id}` | +| Admin Merchant Settings | `https://rewardflow.lu/admin/loyalty/merchants/{id}/settings` | +| Admin API - Programs | `GET https://rewardflow.lu/api/admin/loyalty/programs` | +| Admin API - Stats | `GET https://rewardflow.lu/api/admin/loyalty/stats` | +| Public API - Program | `GET https://rewardflow.lu/api/loyalty/programs/ORION` | +| Apple Wallet Pass | `GET https://rewardflow.lu/api/loyalty/passes/apple/{serial}.pkpass` | + +### Domain configuration per store (current DB state) + +**Merchant domains** (`merchant_domains` table): + +| Merchant | Merchant Domain | Status | +|----------|-----------------|--------| +| WizaCorp Ltd. | _(none yet)_ | — | +| Fashion Group S.A. | _(none yet)_ | — | +| BookWorld Publishing | _(none yet)_ | — | + +**Store domains** (`store_domains` table) and effective resolution: + +| Store | Merchant | Store Custom Domain | Effective Domain | +|-------|----------|---------------------|------------------| +| ORION | WizaCorp | `orion.shop` | `orion.shop` (store override) | +| FASHIONHUB | Fashion Group | `fashionhub.store` | `fashionhub.store` (store override) | +| WIZAGADGETS | WizaCorp | _(none)_ | `wizagadgets.rewardflow.lu` (subdomain fallback) | +| WIZAHOME | WizaCorp | _(none)_ | `wizahome.rewardflow.lu` (subdomain fallback) | +| FASHIONOUTLET | Fashion Group | _(none)_ | `fashionoutlet.rewardflow.lu` (subdomain fallback) | +| BOOKSTORE | BookWorld | _(none)_ | `bookstore.rewardflow.lu` (subdomain fallback) | +| BOOKDIGITAL | BookWorld | _(none)_ | `bookdigital.rewardflow.lu` (subdomain fallback) | + +!!! example "After merchant domain registration" + If WizaCorp registers `myloyaltyprogram.lu` as their merchant domain, the table becomes: + + | Store | Effective Domain | Reason | + |-------|------------------|--------| + | ORION | `orion.shop` | Store custom domain takes priority | + | WIZAGADGETS | `myloyaltyprogram.lu` | Inherits merchant domain | + | WIZAHOME | `myloyaltyprogram.lu` | Inherits merchant domain | + +!!! info "`{store_domain}` in journey URLs" + In the journeys below, `{store_domain}` refers to the store's **effective domain**, resolved in priority order: + + 1. **Store custom domain**: `orion.shop` (from `store_domains` table) — highest priority + 2. **Merchant domain**: `myloyaltyprogram.lu` (from `merchant_domains` table) — inherited default + 3. **Subdomain fallback**: `orion.rewardflow.lu` (from `Store.subdomain` + platform domain) + +--- + +## User Journeys + +### Journey 0: Merchant Subscription & Domain Setup + +**Persona:** Merchant Owner (e.g., john.owner@wizacorp.com) + Platform Admin +**Goal:** Subscribe to the loyalty platform, register a merchant domain, and optionally configure store domain overrides + +```mermaid +flowchart TD + A[Merchant owner logs in] --> B[Navigate to billing page] + B --> C[Choose subscription tier] + C --> D[Complete Stripe checkout] + D --> E[Subscription active] + E --> F{Register merchant domain?} + F -->|Yes| G[Admin registers merchant domain] + G --> H[Verify DNS ownership] + H --> I[Activate merchant domain] + I --> J{Store-specific override?} + J -->|Yes| K[Register store custom domain] + K --> L[Verify & activate store domain] + J -->|No| M[All stores inherit merchant domain] + F -->|No| N[Stores use subdomain fallback] + L --> O[Domain setup complete] + M --> O + N --> O +``` + +**Step 1: Subscribe to the platform** + +1. Login as `john.owner@wizacorp.com` and navigate to billing: + - Dev: `http://localhost:9999/platforms/loyalty/store/ORION/billing` + - Prod (custom domain): `https://orion.shop/store/ORION/billing` + - Prod (subdomain): `https://orion.rewardflow.lu/store/ORION/billing` +2. View available subscription tiers: + - API Dev: `GET http://localhost:9999/platforms/loyalty/api/v1/store/billing/tiers` + - API Prod: `GET https://{store_domain}/api/v1/store/billing/tiers` +3. Select a tier and initiate Stripe checkout: + - API Dev: `POST http://localhost:9999/platforms/loyalty/api/v1/store/billing/checkout` + - API Prod: `POST https://{store_domain}/api/v1/store/billing/checkout` +4. Complete payment on Stripe checkout page +5. Webhook `checkout.session.completed` activates the subscription +6. Verify subscription is active: + - API Dev: `GET http://localhost:9999/platforms/loyalty/api/v1/store/billing/subscription` + - API Prod: `GET https://{store_domain}/api/v1/store/billing/subscription` + +**Step 2: Register merchant domain (admin action)** + +!!! note "Admin-only operation" + Merchant domain registration is currently an admin operation. The platform admin + registers the domain on behalf of the merchant via the admin API. + +1. Platform admin registers a merchant domain: + - API Dev: `POST http://localhost:9999/platforms/loyalty/api/v1/admin/merchants/{merchant_id}/domains` + - API Prod: `POST https://rewardflow.lu/api/v1/admin/merchants/{merchant_id}/domains` + - Body: `{"domain": "myloyaltyprogram.lu", "is_primary": true}` +2. The API returns a `verification_token` for DNS verification +3. Get DNS verification instructions: + - API Dev: `GET http://localhost:9999/platforms/loyalty/api/v1/admin/merchants/domains/merchant/{domain_id}/verification-instructions` + - API Prod: `GET https://rewardflow.lu/api/v1/admin/merchants/domains/merchant/{domain_id}/verification-instructions` +4. Merchant adds a DNS TXT record: `_orion-verify.myloyaltyprogram.lu TXT {verification_token}` +5. Verify the domain: + - API Dev: `POST http://localhost:9999/platforms/loyalty/api/v1/admin/merchants/domains/merchant/{domain_id}/verify` + - API Prod: `POST https://rewardflow.lu/api/v1/admin/merchants/domains/merchant/{domain_id}/verify` +6. Activate the domain: + - API Dev: `PUT http://localhost:9999/platforms/loyalty/api/v1/admin/merchants/domains/merchant/{domain_id}` + - API Prod: `PUT https://rewardflow.lu/api/v1/admin/merchants/domains/merchant/{domain_id}` + - Body: `{"is_active": true}` +7. All merchant stores now inherit `myloyaltyprogram.lu` as their effective domain + +**Step 3: (Optional) Register store-specific domain override** + +If a store needs its own domain (e.g., ORION is a major brand and wants `mysuperloyaltyprogram.lu`): + +1. Platform admin registers a store domain: + - API Dev: `POST http://localhost:9999/platforms/loyalty/api/v1/admin/stores/{store_id}/domains` + - API Prod: `POST https://rewardflow.lu/api/v1/admin/stores/{store_id}/domains` + - Body: `{"domain": "mysuperloyaltyprogram.lu", "is_primary": true}` +2. Follow the same DNS verification and activation flow as merchant domains +3. Once active, this store's effective domain becomes `mysuperloyaltyprogram.lu` (overrides merchant domain) +4. Other stores (WIZAGADGETS, WIZAHOME) continue to use `myloyaltyprogram.lu` + +**Result after domain setup for WizaCorp:** + +| Store | Effective Domain | Source | +|-------|------------------|--------| +| ORION | `mysuperloyaltyprogram.lu` | Store custom domain (override) | +| WIZAGADGETS | `myloyaltyprogram.lu` | Merchant domain (inherited) | +| WIZAHOME | `myloyaltyprogram.lu` | Merchant domain (inherited) | + +**Expected blockers in current state:** + +- No subscriptions exist yet - create one first via billing page or admin API +- No merchant domains registered - admin must register via API +- DNS verification requires actual DNS records (mock in tests) + +--- + +### Journey 1: Merchant Owner - First-Time Setup + +**Persona:** Merchant Owner (e.g., john.owner@wizacorp.com) +**Goal:** Set up a loyalty program for their merchant + +```mermaid +flowchart TD + A[Login as store owner] --> B[Navigate to store loyalty settings] + B --> C{Program exists?} + C -->|No| D[Create loyalty program] + D --> E[Choose type: stamps / points / hybrid] + E --> F[Configure program settings] + F --> G[Set branding - colors, logo] + G --> H[Configure anti-fraud settings] + H --> I[Create staff PINs] + I --> J[Program is live] + C -->|Yes| K[View/edit existing program] +``` + +**Steps:** + +1. Login as `john.owner@wizacorp.com` at: + - Dev: `http://localhost:9999/platforms/loyalty/store/ORION/login` + - Prod (custom domain): `https://orion.shop/store/ORION/login` + - Prod (subdomain): `https://orion.rewardflow.lu/store/ORION/login` +2. Navigate to loyalty settings: + - Dev: `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/settings` + - Prod (custom domain): `https://orion.shop/store/ORION/loyalty/settings` + - Prod (subdomain): `https://orion.rewardflow.lu/store/ORION/loyalty/settings` +3. Create a new loyalty program: + - Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/program` + - Prod: `POST https://{store_domain}/api/store/loyalty/program` +4. Choose loyalty type (stamps, points, or hybrid) +5. Configure program parameters (stamp target, points-per-euro, rewards) +6. Set branding (card color, logo, hero image) +7. Configure anti-fraud (cooldown, daily limits, PIN requirements) +8. Create staff PINs: + - Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/pins` + - Prod: `POST https://{store_domain}/api/store/loyalty/pins` +9. Verify program is live - check from another store (same merchant): + - Dev: `http://localhost:9999/platforms/loyalty/store/WIZAGADGETS/loyalty/settings` + - Prod (subdomain): `https://wizagadgets.rewardflow.lu/store/WIZAGADGETS/loyalty/settings` + +**Expected blockers in current state:** + +- No loyalty programs exist - this is the first journey to test + +!!! note "Subscription is not required for program creation" + The loyalty module currently has **no feature gating** — program creation works + without an active subscription. Journey 0 (subscription & domain setup) is + independent and can be done before or after program creation. However, in production + you would typically subscribe first to get a custom domain for your loyalty URLs. + +--- + +### Journey 2: Store Staff - Daily Operations (Stamps) + +**Persona:** Store Staff (e.g., alice.manager@wizacorp.com) +**Goal:** Process customer loyalty stamp transactions + +```mermaid +flowchart TD + A[Open terminal] --> B[Customer presents card/QR] + B --> C[Scan/lookup card] + C --> D[Enter staff PIN] + D --> E[Add stamp] + E --> F{Target reached?} + F -->|Yes| G[Prompt: Redeem reward?] + G -->|Yes| H[Redeem stamps for reward] + G -->|No| I[Save for later] + F -->|No| J[Done - show updated count] + H --> J + I --> J +``` + +**Steps:** + +1. Login as `alice.manager@wizacorp.com` and open the terminal: + - Dev: `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/terminal` + - Prod: `https://{store_domain}/store/ORION/loyalty/terminal` +2. Scan customer QR code or enter card number: + - Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/cards/lookup` + - Prod: `POST https://{store_domain}/api/store/loyalty/cards/lookup` +3. Enter staff PIN for verification +4. Add stamp: + - Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/stamp` + - Prod: `POST https://{store_domain}/api/store/loyalty/stamp` +5. If target reached, redeem reward: + - Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/stamp/redeem` + - Prod: `POST https://{store_domain}/api/store/loyalty/stamp/redeem` +6. View updated card: + - Dev: `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/cards/{card_id}` + - Prod: `https://{store_domain}/store/ORION/loyalty/cards/{card_id}` +7. Browse all cards: + - Dev: `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/cards` + - Prod: `https://{store_domain}/store/ORION/loyalty/cards` + +**Anti-fraud scenarios to test:** + +- Cooldown rejection (stamp within 15 min) +- Daily limit hit (max 5 stamps/day) +- PIN lockout (5 failed attempts) + +--- + +### Journey 3: Store Staff - Daily Operations (Points) + +**Persona:** Store Staff (e.g., alice.manager@wizacorp.com) +**Goal:** Process customer loyalty points from purchase + +```mermaid +flowchart TD + A[Open terminal] --> B[Customer presents card] + B --> C[Scan/lookup card] + C --> D[Enter purchase amount] + D --> E[Enter staff PIN] + E --> F[Points calculated & added] + F --> G{Enough for reward?} + G -->|Yes| H[Offer redemption] + G -->|No| I[Done - show balance] + H --> I +``` + +**Steps:** + +1. Open the terminal: + - Dev: `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/terminal` + - Prod: `https://{store_domain}/store/ORION/loyalty/terminal` +2. Lookup card: + - Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/cards/lookup` + - Prod: `POST https://{store_domain}/api/store/loyalty/cards/lookup` +3. Enter purchase amount (e.g., 25.00 EUR) +4. Earn points (auto-calculated at 10 pts/EUR = 250 points): + - Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/points` + - Prod: `POST https://{store_domain}/api/store/loyalty/points` +5. If enough balance, redeem points for reward: + - Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/points/redeem` + - Prod: `POST https://{store_domain}/api/store/loyalty/points/redeem` +6. Check store-level stats: + - Dev: `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/stats` + - Prod: `https://{store_domain}/store/ORION/loyalty/stats` + +--- + +### Journey 4: Customer Self-Enrollment + +**Persona:** Anonymous Customer +**Goal:** Join a merchant's loyalty program + +```mermaid +flowchart TD + A[See QR code at store counter] --> B[Scan QR / visit enrollment page] + B --> C[Fill in details - email, name] + C --> D[Submit enrollment] + D --> E[Receive card number] + E --> F[Optional: Add to Apple/Google Wallet] + F --> G[Start collecting stamps/points] +``` + +**Steps:** + +1. Visit the public enrollment page: + - Dev: `http://localhost:9999/platforms/loyalty/loyalty/join` + - Prod (custom domain): `https://orion.shop/loyalty/join` + - Prod (subdomain): `https://bookstore.rewardflow.lu/loyalty/join` +2. Fill in enrollment form (email, name) +3. Submit enrollment: + - Dev: `POST http://localhost:9999/platforms/loyalty/api/storefront/loyalty/enroll` + - Prod (custom domain): `POST https://orion.shop/api/storefront/loyalty/enroll` + - Prod (subdomain): `POST https://bookstore.rewardflow.lu/api/storefront/loyalty/enroll` +4. Redirected to success page: + - Dev: `http://localhost:9999/platforms/loyalty/loyalty/join/success?card=XXXX-XXXX-XXXX` + - Prod (custom domain): `https://orion.shop/loyalty/join/success?card=XXXX-XXXX-XXXX` + - Prod (subdomain): `https://bookstore.rewardflow.lu/loyalty/join/success?card=XXXX-XXXX-XXXX` +5. Optionally download Apple Wallet pass: + - Dev: `GET http://localhost:9999/platforms/loyalty/api/loyalty/passes/apple/{serial_number}.pkpass` + - Prod: `GET https://rewardflow.lu/api/loyalty/passes/apple/{serial_number}.pkpass` + +--- + +### Journey 5: Customer - View Loyalty Status + +**Persona:** Authenticated Customer (e.g., `customer1@orion.example.com`) +**Goal:** Check loyalty balance and history + +**Steps:** + +1. Login as customer at the storefront +2. View loyalty dashboard (card balance, available rewards): + - Dev: `http://localhost:9999/platforms/loyalty/account/loyalty` + - Prod (custom domain): `https://orion.shop/account/loyalty` + - Prod (subdomain): `https://bookstore.rewardflow.lu/account/loyalty` + - API Dev: `GET http://localhost:9999/platforms/loyalty/api/storefront/loyalty/card` + - API Prod: `GET https://orion.shop/api/storefront/loyalty/card` +3. View full transaction history: + - Dev: `http://localhost:9999/platforms/loyalty/account/loyalty/history` + - Prod (custom domain): `https://orion.shop/account/loyalty/history` + - Prod (subdomain): `https://bookstore.rewardflow.lu/account/loyalty/history` + - API Dev: `GET http://localhost:9999/platforms/loyalty/api/storefront/loyalty/transactions` + - API Prod: `GET https://orion.shop/api/storefront/loyalty/transactions` + +--- + +### Journey 6: Platform Admin - Oversight + +**Persona:** Platform Admin (`admin@orion.lu` or `samir.boulahtit@gmail.com`) +**Goal:** Monitor all loyalty programs across merchants + +**Steps:** + +1. Login as admin +2. View all programs: + - Dev: `http://localhost:9999/platforms/loyalty/admin/loyalty/programs` + - Prod: `https://rewardflow.lu/admin/loyalty/programs` +3. View platform-wide analytics: + - Dev: `http://localhost:9999/platforms/loyalty/admin/loyalty/analytics` + - Prod: `https://rewardflow.lu/admin/loyalty/analytics` +4. Drill into WizaCorp's program: + - Dev: `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/1` + - Prod: `https://rewardflow.lu/admin/loyalty/merchants/1` +5. Manage WizaCorp's merchant-level settings: + - Dev: `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/1/settings` + - Prod: `https://rewardflow.lu/admin/loyalty/merchants/1/settings` + - API Dev: `PATCH http://localhost:9999/platforms/loyalty/api/admin/loyalty/merchants/1/settings` + - API Prod: `PATCH https://rewardflow.lu/api/admin/loyalty/merchants/1/settings` +6. Adjust settings: PIN policy, self-enrollment toggle, void permissions +7. Check other merchants: + - Dev: `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/2` + - Prod: `https://rewardflow.lu/admin/loyalty/merchants/2` + +--- + +### Journey 7: Void / Return Flow + +**Persona:** Store Staff (e.g., alice.manager@wizacorp.com) +**Goal:** Reverse a loyalty transaction (customer return) + +**Steps:** + +1. Open terminal and lookup card: + - Dev: `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/terminal` + - Prod: `https://{store_domain}/store/ORION/loyalty/terminal` + - Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/cards/lookup` + - Prod: `POST https://{store_domain}/api/store/loyalty/cards/lookup` +2. View the card's transaction history to find the transaction to void: + - Dev: `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/cards/{card_id}` + - Prod: `https://{store_domain}/store/ORION/loyalty/cards/{card_id}` + - API Dev: `GET http://localhost:9999/platforms/loyalty/api/store/loyalty/cards/{card_id}/transactions` + - API Prod: `GET https://{store_domain}/api/store/loyalty/cards/{card_id}/transactions` +3. Void a stamp transaction: + - Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/stamp/void` + - Prod: `POST https://{store_domain}/api/store/loyalty/stamp/void` +4. Or void a points transaction: + - Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/points/void` + - Prod: `POST https://{store_domain}/api/store/loyalty/points/void` +5. Verify: original and void transactions are linked in the audit log + +--- + +### Journey 8: Cross-Store Redemption + +**Persona:** Customer + Store Staff at two different stores +**Goal:** Customer earns at Store A, redeems at Store B (same merchant) + +**Precondition:** Cross-location redemption must be enabled in merchant settings: + +- Dev: `http://localhost:9999/platforms/loyalty/admin/loyalty/merchants/1/settings` +- Prod: `https://rewardflow.lu/admin/loyalty/merchants/1/settings` + +**Steps:** + +1. Staff at ORION adds stamps to customer's card: + - Dev: `http://localhost:9999/platforms/loyalty/store/ORION/loyalty/terminal` + - Prod: `https://{store_domain}/store/ORION/loyalty/terminal` + - Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/stamp` + - Prod: `POST https://{store_domain}/api/store/loyalty/stamp` +2. Customer visits WIZAGADGETS +3. Staff at WIZAGADGETS looks up the same card: + - Dev: `http://localhost:9999/platforms/loyalty/store/WIZAGADGETS/loyalty/terminal` + - Prod: `https://{store_domain}/store/WIZAGADGETS/loyalty/terminal` + - Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/cards/lookup` + - Prod: `POST https://{store_domain}/api/store/loyalty/cards/lookup` +4. Card is found (same merchant) with accumulated stamps +5. Staff at WIZAGADGETS redeems the reward: + - Dev: `POST http://localhost:9999/platforms/loyalty/api/store/loyalty/stamp/redeem` + - Prod: `POST https://{store_domain}/api/store/loyalty/stamp/redeem` +6. Verify transaction history shows both stores: + - Dev: `http://localhost:9999/platforms/loyalty/store/WIZAGADGETS/loyalty/cards/{card_id}` + - Prod: `https://{store_domain}/store/WIZAGADGETS/loyalty/cards/{card_id}` + +--- + +## Recommended Test Order + +1. **Journey 1** - Create a program first (nothing else works without this) +2. **Journey 0** - Subscribe and set up domains (independent, but needed for custom domain URLs) +3. **Journey 4** - Enroll a test customer +4. **Journey 2 or 3** - Process stamps/points +5. **Journey 5** - Verify customer can see their data +6. **Journey 7** - Test void/return +7. **Journey 8** - Test cross-store (enroll via ORION, redeem via WIZAGADGETS) +8. **Journey 6** - Admin overview (verify data appears correctly) + +!!! tip "Journey 0 and Journey 1 are independent" + There is no feature gating on loyalty program creation — you can test them in + either order. Journey 0 is listed second because domain setup is about URL + presentation, not a functional prerequisite for the loyalty module. diff --git a/app/modules/marketplace/docs/admin-guide.md b/app/modules/marketplace/docs/admin-guide.md new file mode 100644 index 00000000..efd0a19a --- /dev/null +++ b/app/modules/marketplace/docs/admin-guide.md @@ -0,0 +1,261 @@ +# Letzshop Admin Management Guide + +Complete guide for managing Letzshop integration from the Admin Portal at `/admin/marketplace/letzshop`. + +## Table of Contents + +- [Overview](#overview) +- [Store Selection](#store-selection) +- [Products Tab](#products-tab) +- [Orders Tab](#orders-tab) +- [Exceptions Tab](#exceptions-tab) +- [Jobs Tab](#jobs-tab) +- [Settings Tab](#settings-tab) + +--- + +## Overview + +The Letzshop Management page provides a unified interface for managing Letzshop marketplace integration for all stores. Key features: + +- **Multi-Store Support**: Select any store to manage their Letzshop integration +- **Product Management**: View, import, and export products +- **Order Processing**: View orders, confirm inventory, set tracking +- **Exception Handling**: Resolve product matching exceptions +- **Job Monitoring**: Track import, export, and sync operations +- **Configuration**: Manage CSV URLs, credentials, and sync settings + +--- + +## Store Selection + +At the top of the page, use the store autocomplete to select which store to manage: + +1. Type to search for a store by name or code +2. Select from the dropdown +3. The page loads store-specific data for all tabs +4. Your selection is saved and restored on next visit + +**Cross-Store View**: When no store is selected, the Orders and Exceptions tabs show data across all stores. + +--- + +## Products Tab + +The Products tab displays Letzshop marketplace products imported for the selected store. + +### Product Listing + +- **Search**: Filter by title, GTIN, SKU, or brand +- **Status Filter**: Show all, active only, or inactive only +- **Pagination**: Navigate through product pages + +### Product Table Columns + +| Column | Description | +|--------|-------------| +| Product | Image, title, and brand | +| Identifiers | GTIN and SKU codes | +| Price | Product price with currency | +| Status | Active/Inactive badge | +| Actions | View product details | + +### Import Products + +Click the **Import** button to open the import modal: + +1. **Import Single Language**: Select a language and enter the CSV URL +2. **Import All Languages**: Imports from all configured CSV URLs (FR, DE, EN) + +Import settings (batch size) are configured in the Settings tab. + +### Export Products + +Click the **Export** button to export products to the Letzshop pickup folder: + +- Exports all three languages (FR, DE, EN) automatically +- Files are placed in `exports/letzshop/{store_code}/` +- Filename format: `{store_code}_products_{language}.csv` +- The export is logged and appears in the Jobs tab + +Export settings (include inactive products) are configured in the Settings tab. + +--- + +## Orders Tab + +The Orders tab displays orders from Letzshop for the selected store (or all stores if none selected). + +### Order Listing + +- **Search**: Filter by order number, customer name, or email +- **Status Filter**: All, Pending, Confirmed, Shipped, Declined +- **Date Range**: Filter by order date + +### Order Actions + +| Action | Description | +|--------|-------------| +| View | Open order details modal | +| Confirm | Confirm all items in order | +| Decline | Decline all items in order | +| Set Tracking | Add tracking number and carrier | + +### Order Details Modal + +Shows complete order information including: + +- Order number and date +- Customer name and email +- Shipping address +- Order items with confirmation status +- Tracking information (if set) + +--- + +## Exceptions Tab + +The Exceptions tab shows product matching exceptions that need resolution. See the [Order Item Exceptions documentation](../orders/exceptions.md) for details. + +### Exception Types + +When an order is imported and a product cannot be matched by GTIN: + +1. The order is imported with a placeholder product +2. An exception is created for resolution +3. The order cannot be confirmed until exceptions are resolved + +### Resolution Actions + +| Action | Description | +|--------|-------------| +| Resolve | Assign the correct product to the order item | +| Bulk Resolve | Resolve all exceptions for the same GTIN | +| Ignore | Mark as ignored (still blocks confirmation) | + +--- + +## Jobs Tab + +The Jobs tab provides a unified view of all Letzshop-related operations for the selected store. + +### Job Types + +| Type | Icon | Color | Description | +|------|------|-------|-------------| +| Product Import | Cloud Download | Purple | Importing products from Letzshop CSV | +| Product Export | Cloud Upload | Blue | Exporting products to pickup folder | +| Historical Import | Clock | Orange | Importing historical orders | +| Order Sync | Refresh | Indigo | Syncing orders from Letzshop API | + +### Job Information + +Each job displays: + +- **ID**: Unique job identifier +- **Type**: Import, Export, Historical Import, or Order Sync +- **Status**: Pending, Processing, Completed, Failed, or Partial +- **Records**: Success count / Total processed (failed count if any) +- **Started**: When the job began +- **Duration**: How long the job took + +#### Records Column Meaning + +| Job Type | Records Shows | +|----------|---------------| +| Product Import | Products imported / Total products | +| Product Export | Files exported / Total files (3 languages) | +| Historical Import | Orders imported / Total orders | +| Order Sync | Orders synced / Total orders | + +### Filtering + +- **Type Filter**: Show specific job types +- **Status Filter**: Show jobs with specific status + +### Job Actions + +| Action | Description | +|--------|-------------| +| View Errors | Show error details (for failed jobs) | +| View Details | Show complete job information | + +--- + +## Settings Tab + +The Settings tab manages Letzshop integration configuration for the selected store. + +### CSV Feed URLs + +Configure the URLs for Letzshop product CSV feeds: + +- **French (FR)**: URL for French product data +- **German (DE)**: URL for German product data +- **English (EN)**: URL for English product data + +### Import Settings + +- **Batch Size**: Number of products to process per batch (100-5000) + +### Export Settings + +- **Include Inactive**: Whether to include inactive products in exports + +### API Credentials + +Configure Letzshop API access: + +- **API Key**: Your Letzshop API key (encrypted at rest) +- **Test Connection**: Verify API connectivity + +### Sync Settings + +- **Auto-Sync Enabled**: Enable automatic order synchronization +- **Sync Interval**: How often to sync orders (in minutes) + +--- + +## API Endpoints + +### Products + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/admin/products` | GET | List marketplace products with filters | +| `/admin/products/stats` | GET | Get product statistics | +| `/admin/letzshop/stores/{id}/export` | GET | Download CSV export | +| `/admin/letzshop/stores/{id}/export` | POST | Export to pickup folder | + +### Jobs + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/admin/letzshop/stores/{id}/jobs` | GET | List jobs for store | +| `/admin/marketplace-import-jobs` | POST | Create import job | + +### Orders + +See [Letzshop Order Integration](order-integration.md) for complete order API documentation. + +--- + +## Best Practices + +### Product Management + +1. **Regular Imports**: Schedule regular imports to keep product data current +2. **Export Before Sync**: Export products before Letzshop's pickup schedule +3. **Monitor Jobs**: Check the Jobs tab for failed imports/exports + +### Order Processing + +1. **Check Exceptions First**: Resolve exceptions before confirming orders +2. **Verify Tracking**: Ensure tracking numbers are valid before submission +3. **Monitor Sync Status**: Check for failed order syncs in Jobs tab + +### Troubleshooting + +1. **Products Not Appearing**: Verify CSV URL is accessible and valid +2. **Export Failed**: Check write permissions on exports directory +3. **Orders Not Syncing**: Verify API credentials and test connection diff --git a/app/modules/marketplace/docs/api.md b/app/modules/marketplace/docs/api.md new file mode 100644 index 00000000..cde0a59c --- /dev/null +++ b/app/modules/marketplace/docs/api.md @@ -0,0 +1,322 @@ +# Letzshop Marketplace Integration + +## Introduction + +This guide covers the Orion platform's integration with the Letzshop marketplace, including: + +- **Product Export**: Generate Letzshop-compatible CSV files from your product catalog +- **Order Import**: Fetch and manage orders from Letzshop +- **Fulfillment Sync**: Confirm/reject orders, set tracking numbers +- **GraphQL API Reference**: Direct API access for advanced use cases + +--- + +## Product Export + +### Overview + +The Orion platform can export your products to Letzshop-compatible CSV format (Google Shopping feed format). This allows you to: + +- Upload your product catalog to Letzshop marketplace +- Generate feeds in multiple languages (English, French, German) +- Include all required Letzshop fields automatically + +### API Endpoints + +#### Store Export + +```http +GET /api/v1/store/letzshop/export?language=fr +Authorization: Bearer +``` + +**Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `language` | string | `en` | Language for title/description (`en`, `fr`, `de`) | +| `include_inactive` | bool | `false` | Include inactive products | + +**Response:** CSV file download (`store_code_letzshop_export.csv`) + +#### Admin Export + +```http +GET /api/v1/admin/letzshop/export?store_id=1&language=fr +Authorization: Bearer +``` + +**Additional Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `store_id` | int | Required. Store ID to export | + +### CSV Format + +The export generates a tab-separated CSV file with these columns: + +| Column | Description | Example | +|--------|-------------|---------| +| `id` | Product SKU | `PROD-001` | +| `title` | Product title (localized) | `Wireless Headphones` | +| `description` | Product description (localized) | `High-quality...` | +| `link` | Product URL | `https://shop.example.com/product/123` | +| `image_link` | Main product image | `https://cdn.example.com/img.jpg` | +| `additional_image_link` | Additional images (comma-separated) | `img2.jpg,img3.jpg` | +| `availability` | Stock status | `in stock` / `out of stock` | +| `price` | Regular price with currency | `49.99 EUR` | +| `sale_price` | Sale price with currency | `39.99 EUR` | +| `brand` | Brand name | `TechBrand` | +| `gtin` | Global Trade Item Number | `0012345678901` | +| `mpn` | Manufacturer Part Number | `TB-WH-001` | +| `google_product_category` | Google category ID | `Electronics > Audio` | +| `condition` | Product condition | `new` / `used` / `refurbished` | +| `atalanda:tax_rate` | Luxembourg VAT rate | `17` | + +### Multi-Language Support + +Products are exported with localized content based on the `language` parameter: + +```bash +# French export +curl -H "Authorization: Bearer $TOKEN" \ + "https://api.example.com/api/v1/store/letzshop/export?language=fr" + +# German export +curl -H "Authorization: Bearer $TOKEN" \ + "https://api.example.com/api/v1/store/letzshop/export?language=de" + +# English export (default) +curl -H "Authorization: Bearer $TOKEN" \ + "https://api.example.com/api/v1/store/letzshop/export?language=en" +``` + +If a translation is not available for the requested language, the system falls back to English, then to any available translation. + +### Using the Export + +1. **Navigate to Letzshop** in your store dashboard +2. **Click the Export tab** +3. **Select your language** (French, German, or English) +4. **Click "Download CSV"** +5. **Upload to Letzshop** via their merchant portal + +--- + +## Order Integration + +For details on order import and fulfillment, see [Letzshop Order Integration](order-integration.md). + +--- + +## Letzshop GraphQL API Reference + +The following sections document the Letzshop GraphQL API for direct integration. + +--- + +## GraphQL API + +Utilizing this API, you can retrieve and modify data on products, stores, and shipments. Letzshop uses GraphQL, allowing for precise queries. + +**Endpoint**: +http://letzshop.lu/graphql +Replace YOUR_API_ACCESS_KEY with your actual key or remove the Authorization header for public data. + +## Authentication + +Some data is public (e.g., store description and product prices). +For protected operations (e.g., orders or vouchers), an API key is required. + +Request one via your account manager or email: support@letzshop.lu + + +Include the key: + + +Authorization: Bearer YOUR_API_ACCESS_KEY + +--- + +## Playground + +- Access the interactive GraphQL Playground via the Letzshop website. +- It provides live docs, auto-complete (CTRL + Space), and run-time testing. +- **Caution**: You're working on production—mutations like confirming orders are real. [1](https://letzshop.lu/en/dev) + +--- + +## Deprecations + +The following GraphQL fields will be removed soon: + +| Type | Field | Replacement | +|---------|---------------------|----------------------------------| +| Event | latitude, longitude | `#lat`, `#lng` | +| Greco | weight | `packages.weight` | +| Product | ageRestriction | `_age_restriction` (int) | +| Taxon | identifier | `slug` | +| User | billAddress, shipAddress | on `orders` instead | +| Store | facebookLink, instagramLink, twitterLink, youtubeLink | `social_media_links` | +| Store | owner | `representative` | +| Store | permalink | `slug` | [1](https://letzshop.lu/en/dev) + +--- + +## Order Management via API + +Using the API, you can: + +- Fetch unconfirmed orders +- Confirm or reject them +- Set tracking numbers +- Handle returns + +All of this requires at least "shop manager" API key access. Multi-store management is supported if rights allow. [1](https://letzshop.lu/en/dev) + +### 1. Retrieve Unconfirmed Shipments + +**Query:** +```graphql +query { + shipments(state: unconfirmed) { + nodes { + id + inventoryUnits { + id + state + } + } + } +} +``` [1](https://letzshop.lu/en/dev) + +--- + +### 2. Confirm/Reject Inventory Units + +Use inventoryUnit IDs to confirm or reject: + +```graphql +mutation { + confirmInventoryUnits(input: { + inventoryUnits: [ + { inventoryUnitId: "ID1", isAvailable: true }, + { inventoryUnitId: "ID2", isAvailable: false }, + { inventoryUnitId: "ID3", isAvailable: false } + ] + }) { + inventoryUnits { + id + state + } + errors { + id + code + message + } + } +} +``` [1](https://letzshop.lu/en/dev) + +--- + +### 3. Handle Customer Returns + +Use only after receiving returned items: + +```graphql +mutation { + returnInventoryUnits(input: { + inventoryUnits: [ + { inventoryUnitId: "ID1" }, + { inventoryUnitId: "ID2" } + ] + }) { + inventoryUnits { + id + state + } + errors { + id + code + } + } +} +``` [1](https://letzshop.lu/en/dev) + +--- + +### 4. Set Shipment Tracking Number + +Include shipping provider and tracking code: + +```graphql +mutation { + setShipmentTracking(input: { + shipmentId: "SHIPMENT_ID", + code: "TRACK123", + provider: THE_SHIPPING_PROVIDER + }) { + shipment { + tracking { + code + provider + } + } + errors { + code + message + } + } +} +``` [1](https://letzshop.lu/en/dev) + +--- + +## Event System + +Subscribe by contacting support@letzshop.lu. Events are delivered via an SNS-like message structure: + +```json +{ + "Type": "Notification", + "MessageId": "XXX", + "TopicArn": "arn:aws:sns:eu-central-1:XXX:events", + "Message": "{\"id\":\"XXX\",\"type\":\"XXX\",\"payload\":{...}}", + "Timestamp": "2019-01-01T00:00:00.000Z", + "SignatureVersion": "1", + "Signature": "XXX", + "SigningCertURL": "...", + "UnsubscribeURL": "..." +} +``` [1](https://letzshop.lu/en/dev) + +### Message Payload + +Each event includes: + +- `id` +- `type` (e.g., ShipmentConfirmed, UserCreated…) +- `payload` (object-specific data) [1](https://letzshop.lu/en/dev) + +--- + +## Event Types & Payload Structure + +A variety of event types are supported. Common ones include: + +- `ShipmentConfirmed`, `ShipmentRefundCreated` +- `UserAnonymized`, `UserCreated`, `UserDestroyed`, `UserUpdated` +- `VariantWithPriceCrossedCreated`, `...Updated` +- `StoreCategoryCreated`, `Destroyed`, `Updated` +- `StoreCreated`, `Destroyed`, `Updated` + +Exact payload structure varies per event type. [1](https://letzshop.lu/en/dev) + +--- + +## Conclusion + +This Markdown file captures all information from the Letzshop development page, formatted for use in your documentation or GitHub. diff --git a/app/modules/marketplace/docs/architecture.md b/app/modules/marketplace/docs/architecture.md new file mode 100644 index 00000000..ebfc4c76 --- /dev/null +++ b/app/modules/marketplace/docs/architecture.md @@ -0,0 +1,1345 @@ +# Multi-Marketplace Integration Architecture + +## Executive Summary + +This document defines the complete architecture for integrating Orion with multiple external marketplaces (Letzshop, Amazon, eBay) and digital product suppliers (CodesWholesale). The integration is **bidirectional**, supporting both inbound flows (products, orders) and outbound flows (inventory sync, fulfillment status). + +**Key Capabilities:** + +| Capability | Description | +|------------|-------------| +| **Multi-Marketplace Products** | Import and normalize products from multiple sources | +| **Multi-Language Support** | Translations with language fallback | +| **Unified Order Management** | Orders from all channels in one place | +| **Digital Product Fulfillment** | On-demand license key retrieval from suppliers | +| **Inventory Sync** | Real-time and scheduled stock updates to marketplaces | +| **Fulfillment Sync** | Order status and tracking back to marketplaces | + +--- + +## System Overview + +```mermaid +graph TB + subgraph "External Systems" + LS[Letzshop
CSV + GraphQL] + AZ[Amazon
API] + EB[eBay
API] + CW[CodesWholesale
Digital Supplier API] + WS[Store Storefront
Orion Shop] + end + + subgraph "Integration Layer" + subgraph "Inbound Adapters" + PI[Product Importers] + OI[Order Importers] + DPI[Digital Product Importer] + end + subgraph "Outbound Adapters" + IS[Inventory Sync] + FS[Fulfillment Sync] + PE[Product Export] + end + end + + subgraph "Orion Core" + MP[Marketplace Products] + P[Store Products] + O[Unified Orders] + I[Inventory] + F[Fulfillment] + DL[Digital License Pool] + end + + LS -->|CSV Import| PI + AZ -->|API Pull| PI + EB -->|API Pull| PI + CW -->|Catalog Sync| DPI + + LS -->|GraphQL Poll| OI + AZ -->|API Poll| OI + EB -->|API Poll| OI + WS -->|Direct| O + + PI --> MP + DPI --> MP + MP --> P + OI --> O + + I -->|Real-time/Scheduled| IS + F -->|Status Update| FS + P -->|CSV Export| PE + + IS --> LS + IS --> AZ + IS --> EB + FS --> LS + FS --> AZ + FS --> EB + PE --> LS + + CW -->|On-demand Keys| DL + DL --> F +``` + +--- + +## Integration Phases + +| Phase | Scope | Priority | Dependencies | +|-------|-------|----------|--------------| +| **Phase 1** | Product Import | High | None | +| **Phase 2** | Order Import | High | Phase 1 | +| **Phase 3** | Order Fulfillment Sync | High | Phase 2 | +| **Phase 4** | Inventory Sync | Medium | Phase 1 | + +--- + +## Marketplace Capabilities Matrix + +| Marketplace | Products In | Products Out | Orders In | Fulfillment Out | Inventory Out | Method | +|-------------|-------------|--------------|-----------|-----------------|---------------|--------| +| **Letzshop** | CSV Import | CSV Export | GraphQL Poll | GraphQL | CSV/GraphQL | File + API | +| **Amazon** | API | N/A | API Poll | API | API (Real-time) | API | +| **eBay** | API | N/A | API Poll | API | API (Real-time) | API | +| **CodesWholesale** | API Catalog | N/A | N/A | On-demand Keys | N/A | API | +| **Store Storefront** | N/A | N/A | Direct DB | Internal | Internal | Direct | + +--- + +## Part 1: Product Integration + +### 1.1 Architecture Overview + +```mermaid +graph TB + subgraph "Source Layer" + LS_CSV[Letzshop CSV
Multi-language feeds] + AZ_API[Amazon API
Product catalog] + EB_API[eBay API
Product catalog] + CW_API[CodesWholesale API
Digital catalog] + end + + subgraph "Import Layer" + LSI[LetzshopImporter] + AZI[AmazonImporter] + EBI[EbayImporter] + CWI[CodesWholesaleImporter] + end + + subgraph "Canonical Layer" + MP[(marketplace_products)] + MPT[(marketplace_product_translations)] + end + + subgraph "Store Layer" + P[(products)] + PT[(product_translations)] + end + + LS_CSV --> LSI + AZ_API --> AZI + EB_API --> EBI + CW_API --> CWI + + LSI --> MP + AZI --> MP + EBI --> MP + CWI --> MP + + MP --> MPT + MP --> P + P --> PT +``` + +### 1.2 Digital Product Supplier Integration (CodesWholesale) + +CodesWholesale provides digital products (game keys, gift cards, software licenses) that need special handling: + +```mermaid +sequenceDiagram + participant CW as CodesWholesale API + participant Sync as Catalog Sync Job + participant MP as marketplace_products + participant P as products + participant LP as License Pool + + Note over Sync: Scheduled catalog sync (e.g., every 6 hours) + Sync->>CW: GET /products (catalog) + CW-->>Sync: Product catalog with prices, availability + Sync->>MP: Upsert products (marketplace='codeswholesale') + Sync->>MP: Update prices, availability flags + + Note over P: Store adds product to their catalog + P->>MP: Link to marketplace_product + + Note over LP: Order placed - need license key + LP->>CW: POST /orders (purchase key on-demand) + CW-->>LP: License key / download link + LP->>LP: Store for fulfillment +``` + +**Key Characteristics:** + +| Aspect | Behavior | +|--------|----------| +| **Catalog Sync** | Scheduled job fetches full catalog, updates prices/availability | +| **License Keys** | Purchased on-demand at fulfillment time (not pre-stocked) | +| **Inventory** | Virtual - always "available" but subject to supplier stock | +| **Pricing** | Dynamic - supplier prices may change, store sets markup | +| **Regions** | Products may have region restrictions (EU, US, Global) | + +### 1.3 Product Data Model + +See [Multi-Marketplace Product Architecture](../../development/migration/multi-marketplace-product-architecture.md) for detailed schema. + +**Summary of tables:** + +| Table | Purpose | +|-------|---------| +| `marketplace_products` | Canonical product data from all sources | +| `marketplace_product_translations` | Localized titles, descriptions per language | +| `products` | Store-specific overrides and settings | +| `product_translations` | Store-specific localized overrides | + +### 1.4 Import Job Flow + +```mermaid +stateDiagram-v2 + [*] --> Pending: Job Created + Pending --> Processing: Worker picks up + Processing --> Downloading: Fetch source data + Downloading --> Parsing: Parse rows + Parsing --> Upserting: Update database + Upserting --> Completed: All rows processed + Upserting --> PartiallyCompleted: Some rows failed + Processing --> Failed: Fatal error + Completed --> [*] + PartiallyCompleted --> [*] + Failed --> [*] +``` + +--- + +## Part 2: Order Integration + +### 2.1 Unified Order Model + +Orders from all channels (marketplaces + store storefront) flow into a unified order management system. + +```mermaid +graph TB + subgraph "Order Sources" + LS_O[Letzshop Orders
GraphQL Poll] + AZ_O[Amazon Orders
API Poll] + EB_O[eBay Orders
API Poll] + VS_O[Store Storefront
Direct] + end + + subgraph "Order Import" + OI[Order Importer Service] + OQ[Order Import Queue] + end + + subgraph "Unified Orders" + O[(orders)] + OI_T[(order_items)] + OS[(order_status_history)] + end + + LS_O -->|Poll every N min| OI + AZ_O -->|Poll every N min| OI + EB_O -->|Poll every N min| OI + VS_O -->|Direct insert| O + + OI --> OQ + OQ --> O + O --> OI_T + O --> OS +``` + +### 2.2 Order Data Model + +```python +class OrderChannel(str, Enum): + """Order source channel.""" + STOREFRONT = "storefront" # Store's own Orion shop + LETZSHOP = "letzshop" + AMAZON = "amazon" + EBAY = "ebay" + +class OrderStatus(str, Enum): + """Unified order status.""" + PENDING = "pending" # Awaiting payment/confirmation + CONFIRMED = "confirmed" # Payment confirmed + PROCESSING = "processing" # Being prepared + READY_FOR_SHIPMENT = "ready_for_shipment" # Physical: packed + SHIPPED = "shipped" # Physical: in transit + DELIVERED = "delivered" # Physical: delivered + FULFILLED = "fulfilled" # Digital: key/download sent + CANCELLED = "cancelled" + REFUNDED = "refunded" + PARTIALLY_REFUNDED = "partially_refunded" + +class Order(Base, TimestampMixin): + """Unified order from all channels.""" + __tablename__ = "orders" + + id = Column(Integer, primary_key=True) + store_id = Column(Integer, ForeignKey("stores.id"), nullable=False) + + # === CHANNEL TRACKING === + channel = Column(SQLEnum(OrderChannel), nullable=False, index=True) + channel_order_id = Column(String, index=True) # External order ID + channel_order_url = Column(String) # Link to order in marketplace + + # === CUSTOMER INFO === + customer_id = Column(Integer, ForeignKey("customers.id"), nullable=True) + customer_email = Column(String, nullable=False) + customer_name = Column(String) + customer_phone = Column(String) + + # === ADDRESSES === + shipping_address = Column(JSON) # For physical products + billing_address = Column(JSON) + + # === ORDER TOTALS === + subtotal = Column(Float, nullable=False) + shipping_cost = Column(Float, default=0) + tax_amount = Column(Float, default=0) + discount_amount = Column(Float, default=0) + total = Column(Float, nullable=False) + currency = Column(String(3), default="EUR") + + # === STATUS === + status = Column(SQLEnum(OrderStatus), default=OrderStatus.PENDING, index=True) + + # === FULFILLMENT TYPE === + requires_shipping = Column(Boolean, default=True) # False for digital-only + is_fully_digital = Column(Boolean, default=False) + + # === TIMESTAMPS === + ordered_at = Column(DateTime, nullable=False) # When customer placed order + confirmed_at = Column(DateTime) + shipped_at = Column(DateTime) + delivered_at = Column(DateTime) + fulfilled_at = Column(DateTime) # For digital products + + # === SYNC STATUS === + last_synced_at = Column(DateTime) # Last sync with marketplace + sync_status = Column(String) # 'synced', 'pending', 'error' + sync_error = Column(Text) + + # === RELATIONSHIPS === + store = relationship("Store", back_populates="orders") + customer = relationship("Customer", back_populates="orders") + items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan") + status_history = relationship("OrderStatusHistory", back_populates="order") + + __table_args__ = ( + Index("idx_order_store_status", "store_id", "status"), + Index("idx_order_channel", "channel", "channel_order_id"), + Index("idx_order_store_date", "store_id", "ordered_at"), + ) + + +class OrderItem(Base, TimestampMixin): + """Individual item in an order.""" + __tablename__ = "order_items" + + id = Column(Integer, primary_key=True) + order_id = Column(Integer, ForeignKey("orders.id", ondelete="CASCADE"), nullable=False) + product_id = Column(Integer, ForeignKey("products.id"), nullable=True) + + # === PRODUCT SNAPSHOT (at time of order) === + product_name = Column(String, nullable=False) + product_sku = Column(String) + product_gtin = Column(String) + product_image_url = Column(String) + + # === PRICING === + unit_price = Column(Float, nullable=False) + quantity = Column(Integer, nullable=False, default=1) + subtotal = Column(Float, nullable=False) + tax_amount = Column(Float, default=0) + discount_amount = Column(Float, default=0) + total = Column(Float, nullable=False) + + # === PRODUCT TYPE === + is_digital = Column(Boolean, default=False) + + # === DIGITAL FULFILLMENT === + license_key = Column(String) # For digital products + download_url = Column(String) + download_expiry = Column(DateTime) + digital_fulfilled_at = Column(DateTime) + + # === PHYSICAL FULFILLMENT === + shipped_quantity = Column(Integer, default=0) + + # === SUPPLIER TRACKING (for CodesWholesale etc) === + supplier = Column(String) # 'codeswholesale', 'internal', etc. + supplier_order_id = Column(String) # Supplier's order reference + supplier_cost = Column(Float) # What we paid supplier + + # === RELATIONSHIPS === + order = relationship("Order", back_populates="items") + product = relationship("Product") + + +class OrderStatusHistory(Base, TimestampMixin): + """Audit trail of order status changes.""" + __tablename__ = "order_status_history" + + id = Column(Integer, primary_key=True) + order_id = Column(Integer, ForeignKey("orders.id", ondelete="CASCADE"), nullable=False) + + from_status = Column(SQLEnum(OrderStatus)) + to_status = Column(SQLEnum(OrderStatus), nullable=False) + changed_by = Column(String) # 'system', 'store:123', 'marketplace:letzshop' + reason = Column(String) + metadata = Column(JSON) # Additional context (tracking number, etc.) + + order = relationship("Order", back_populates="status_history") +``` + +### 2.3 Order Import Flow + +```mermaid +sequenceDiagram + participant Scheduler as Scheduler + participant Poller as Order Poller + participant MP as Marketplace API + participant Queue as Import Queue + participant Worker as Import Worker + participant DB as Database + participant Notify as Notification Service + + Scheduler->>Poller: Trigger poll (every N minutes) + Poller->>MP: Fetch orders since last_sync + MP-->>Poller: New/updated orders + + loop For each order + Poller->>Queue: Enqueue order import job + end + + Worker->>Queue: Pick up job + Worker->>DB: Check if order exists (by channel_order_id) + + alt New Order + Worker->>DB: Create order + items + Worker->>Notify: New order notification + else Existing Order + Worker->>DB: Update order status/details + end + + Worker->>DB: Update last_synced_at +``` + +### 2.4 Letzshop Order Integration (GraphQL) + +```python +# Example GraphQL queries for Letzshop order integration + +LETZSHOP_ORDERS_QUERY = """ +query GetOrders($since: DateTime, $status: [OrderStatus!]) { + orders( + filter: { + updatedAt: { gte: $since } + status: { in: $status } + } + first: 100 + ) { + edges { + node { + id + orderNumber + status + createdAt + updatedAt + customer { + email + firstName + lastName + phone + } + shippingAddress { + street + city + postalCode + country + } + items { + product { + id + sku + name + } + quantity + unitPrice + totalPrice + } + totals { + subtotal + shipping + tax + total + currency + } + } + } + pageInfo { + hasNextPage + endCursor + } + } +} +""" + +class LetzshopOrderImporter: + """Import orders from Letzshop via GraphQL.""" + + def __init__(self, store_id: int, api_url: str, api_token: str): + self.store_id = store_id + self.api_url = api_url + self.api_token = api_token + + async def fetch_orders_since(self, since: datetime) -> list[dict]: + """Fetch orders updated since given timestamp.""" + # Implementation: Execute GraphQL query + pass + + def map_to_order(self, letzshop_order: dict) -> OrderCreate: + """Map Letzshop order to unified Order schema.""" + return OrderCreate( + store_id=self.store_id, + channel=OrderChannel.LETZSHOP, + channel_order_id=letzshop_order["id"], + customer_email=letzshop_order["customer"]["email"], + customer_name=f"{letzshop_order['customer']['firstName']} {letzshop_order['customer']['lastName']}", + status=self._map_status(letzshop_order["status"]), + # ... map remaining fields + ) + + def _map_status(self, letzshop_status: str) -> OrderStatus: + """Map Letzshop status to unified status.""" + mapping = { + "PENDING": OrderStatus.PENDING, + "PAID": OrderStatus.CONFIRMED, + "PROCESSING": OrderStatus.PROCESSING, + "SHIPPED": OrderStatus.SHIPPED, + "DELIVERED": OrderStatus.DELIVERED, + "CANCELLED": OrderStatus.CANCELLED, + } + return mapping.get(letzshop_status, OrderStatus.PENDING) +``` + +--- + +## Part 3: Order Fulfillment Sync + +### 3.1 Fulfillment Architecture + +```mermaid +graph TB + subgraph "Store Actions" + VA[Store marks order shipped] + VD[Store marks delivered] + VF[Digital fulfillment triggered] + end + + subgraph "Fulfillment Service" + FS[Fulfillment Service] + DFS[Digital Fulfillment Service] + end + + subgraph "Outbound Sync" + SQ[Sync Queue] + SW[Sync Workers] + end + + subgraph "External Systems" + LS_API[Letzshop GraphQL] + AZ_API[Amazon API] + EB_API[eBay API] + CW_API[CodesWholesale API] + EMAIL[Email Service] + end + + VA --> FS + VD --> FS + VF --> DFS + + FS --> SQ + DFS --> CW_API + DFS --> EMAIL + + SQ --> SW + SW --> LS_API + SW --> AZ_API + SW --> EB_API +``` + +### 3.2 Physical Product Fulfillment + +```mermaid +sequenceDiagram + participant Store as Store UI + participant API as Fulfillment API + participant DB as Database + participant Queue as Sync Queue + participant Worker as Sync Worker + participant MP as Marketplace API + + Store->>API: Mark order as shipped (tracking #) + API->>DB: Update order status + API->>DB: Add status history entry + API->>Queue: Enqueue fulfillment sync job + + Worker->>Queue: Pick up job + Worker->>DB: Get order details + Worker->>MP: Update fulfillment status + + alt Sync Success + MP-->>Worker: 200 OK + Worker->>DB: Mark sync_status='synced' + else Sync Failed + MP-->>Worker: Error + Worker->>DB: Mark sync_status='error', store error + Worker->>Queue: Retry with backoff + end +``` + +### 3.3 Digital Product Fulfillment (CodesWholesale) + +```mermaid +sequenceDiagram + participant Order as Order Service + participant DFS as Digital Fulfillment Service + participant CW as CodesWholesale API + participant DB as Database + participant Email as Email Service + participant Customer as Customer + + Note over Order: Order confirmed, contains digital item + Order->>DFS: Fulfill digital items + + loop For each digital item + DFS->>DB: Check product supplier + + alt Supplier = CodesWholesale + DFS->>CW: POST /orders (purchase key) + CW-->>DFS: License key + download info + DFS->>DB: Store license key on order_item + else Internal / Pre-loaded + DFS->>DB: Get key from license pool + DFS->>DB: Mark key as used + end + end + + DFS->>DB: Update order status to FULFILLED + DFS->>Email: Send fulfillment email + Email->>Customer: License keys + download links +``` + +### 3.4 Fulfillment Service Implementation + +```python +class FulfillmentService: + """Service for managing order fulfillment across channels.""" + + def __init__( + self, + db: Session, + digital_fulfillment: "DigitalFulfillmentService", + sync_queue: "SyncQueue", + ): + self.db = db + self.digital_fulfillment = digital_fulfillment + self.sync_queue = sync_queue + + async def mark_shipped( + self, + order_id: int, + tracking_number: str | None = None, + carrier: str | None = None, + shipped_items: list[int] | None = None, # Partial shipment + ) -> Order: + """Mark order (or items) as shipped.""" + order = self._get_order(order_id) + + # Update order + order.status = OrderStatus.SHIPPED + order.shipped_at = datetime.utcnow() + + # Add tracking info + if tracking_number: + self._add_status_history( + order, + OrderStatus.SHIPPED, + metadata={"tracking_number": tracking_number, "carrier": carrier} + ) + + # Queue sync to marketplace + if order.channel != OrderChannel.STOREFRONT: + self.sync_queue.enqueue( + SyncJob( + type="fulfillment", + order_id=order_id, + channel=order.channel, + data={"tracking_number": tracking_number, "carrier": carrier} + ) + ) + + self.db.commit() + return order + + async def fulfill_digital_items(self, order_id: int) -> Order: + """Fulfill digital items in order.""" + order = self._get_order(order_id) + + digital_items = [item for item in order.items if item.is_digital] + + for item in digital_items: + await self.digital_fulfillment.fulfill_item(item) + + # Check if fully fulfilled + if all(item.digital_fulfilled_at for item in digital_items): + if order.is_fully_digital: + order.status = OrderStatus.FULFILLED + order.fulfilled_at = datetime.utcnow() + + self.db.commit() + return order + + +class DigitalFulfillmentService: + """Service for fulfilling digital products.""" + + def __init__( + self, + db: Session, + codeswholesale_client: "CodesWholesaleClient", + email_service: "EmailService", + ): + self.db = db + self.codeswholesale = codeswholesale_client + self.email_service = email_service + + async def fulfill_item(self, item: OrderItem) -> OrderItem: + """Fulfill a single digital order item.""" + if item.digital_fulfilled_at: + return item # Already fulfilled + + # Get license key based on supplier + if item.supplier == "codeswholesale": + key_data = await self._fulfill_from_codeswholesale(item) + else: + key_data = await self._fulfill_from_internal_pool(item) + + # Update item + item.license_key = key_data.get("license_key") + item.download_url = key_data.get("download_url") + item.download_expiry = key_data.get("expiry") + item.digital_fulfilled_at = datetime.utcnow() + item.supplier_order_id = key_data.get("supplier_order_id") + item.supplier_cost = key_data.get("cost") + + return item + + async def _fulfill_from_codeswholesale(self, item: OrderItem) -> dict: + """Purchase key from CodesWholesale on-demand.""" + # Get the marketplace product to find CodesWholesale product ID + product = item.product + mp = product.marketplace_product + + if mp.marketplace != "codeswholesale": + raise ValueError(f"Product {product.id} is not from CodesWholesale") + + # Purchase from CodesWholesale + result = await self.codeswholesale.purchase_code( + product_id=mp.marketplace_product_id, + quantity=item.quantity, + ) + + return { + "license_key": result["codes"][0]["code"], # First code for qty=1 + "download_url": result.get("download_url"), + "supplier_order_id": result["order_id"], + "cost": result["total_price"], + } + + async def _fulfill_from_internal_pool(self, item: OrderItem) -> dict: + """Get key from internal pre-loaded pool.""" + # Implementation for stores who pre-load their own keys + pass +``` + +### 3.5 Letzshop Fulfillment Sync (GraphQL) + +```python +LETZSHOP_UPDATE_FULFILLMENT = """ +mutation UpdateOrderFulfillment($orderId: ID!, $input: FulfillmentInput!) { + updateOrderFulfillment(orderId: $orderId, input: $input) { + order { + id + status + fulfillment { + status + trackingNumber + carrier + shippedAt + } + } + errors { + field + message + } + } +} +""" + +class LetzshopFulfillmentSync: + """Sync fulfillment status to Letzshop.""" + + async def sync_shipment( + self, + order: Order, + tracking_number: str | None, + carrier: str | None, + ) -> SyncResult: + """Update Letzshop with shipment info.""" + variables = { + "orderId": order.channel_order_id, + "input": { + "status": "SHIPPED", + "trackingNumber": tracking_number, + "carrier": carrier, + "shippedAt": order.shipped_at.isoformat(), + } + } + + result = await self._execute_mutation( + LETZSHOP_UPDATE_FULFILLMENT, + variables + ) + + if result.get("errors"): + return SyncResult(success=False, errors=result["errors"]) + + return SyncResult(success=True) +``` + +--- + +## Part 4: Inventory Sync + +### 4.1 Inventory Sync Architecture + +```mermaid +graph TB + subgraph "Inventory Changes" + OC[Order Created
Reserve stock] + OF[Order Fulfilled
Deduct stock] + OX[Order Cancelled
Release stock] + MA[Manual Adjustment] + SI[Stock Import] + end + + subgraph "Inventory Service" + IS[Inventory Service] + EQ[Event Queue] + end + + subgraph "Sync Strategy" + RT[Real-time Sync
For API marketplaces] + SC[Scheduled Batch
For file-based] + end + + subgraph "Outbound" + LS_S[Letzshop
CSV/GraphQL] + AZ_S[Amazon API] + EB_S[eBay API] + end + + OC --> IS + OF --> IS + OX --> IS + MA --> IS + SI --> IS + + IS --> EQ + EQ --> RT + EQ --> SC + + RT --> AZ_S + RT --> EB_S + SC --> LS_S +``` + +### 4.2 Sync Strategies + +| Strategy | Use Case | Trigger | Marketplaces | +|----------|----------|---------|--------------| +| **Real-time** | API-based marketplaces | Inventory change event | Amazon, eBay | +| **Scheduled Batch** | File-based or rate-limited | Cron job (configurable) | Letzshop | +| **On-demand** | Manual trigger | Store action | All | + +### 4.3 Inventory Data Model Extensions + +```python +class InventorySyncConfig(Base, TimestampMixin): + """Per-store, per-marketplace sync configuration.""" + __tablename__ = "inventory_sync_configs" + + id = Column(Integer, primary_key=True) + store_id = Column(Integer, ForeignKey("stores.id"), nullable=False) + marketplace = Column(String, nullable=False) # 'letzshop', 'amazon', 'ebay' + + # === SYNC SETTINGS === + is_enabled = Column(Boolean, default=True) + sync_strategy = Column(String, default="scheduled") # 'realtime', 'scheduled', 'manual' + sync_interval_minutes = Column(Integer, default=15) # For scheduled + + # === CREDENTIALS === + api_credentials = Column(JSON) # Encrypted credentials + + # === STOCK RULES === + safety_stock = Column(Integer, default=0) # Reserve buffer + out_of_stock_threshold = Column(Integer, default=0) + sync_zero_stock = Column(Boolean, default=True) # Sync when stock=0 + + # === STATUS === + last_sync_at = Column(DateTime) + last_sync_status = Column(String) + last_sync_error = Column(Text) + items_synced_count = Column(Integer, default=0) + + __table_args__ = ( + UniqueConstraint("store_id", "marketplace", name="uq_inventory_sync_store_marketplace"), + ) + + +class InventorySyncLog(Base, TimestampMixin): + """Log of inventory sync operations.""" + __tablename__ = "inventory_sync_logs" + + id = Column(Integer, primary_key=True) + store_id = Column(Integer, ForeignKey("stores.id"), nullable=False) + marketplace = Column(String, nullable=False) + + sync_type = Column(String) # 'full', 'incremental', 'single_product' + started_at = Column(DateTime, nullable=False) + completed_at = Column(DateTime) + + status = Column(String) # 'success', 'partial', 'failed' + items_processed = Column(Integer, default=0) + items_succeeded = Column(Integer, default=0) + items_failed = Column(Integer, default=0) + + errors = Column(JSON) # List of errors +``` + +### 4.4 Inventory Sync Service + +```python +class InventorySyncService: + """Service for syncing inventory to marketplaces.""" + + def __init__( + self, + db: Session, + sync_adapters: dict[str, "MarketplaceSyncAdapter"], + ): + self.db = db + self.adapters = sync_adapters + + async def sync_inventory_change( + self, + product_id: int, + new_quantity: int, + change_reason: str, + ): + """Handle inventory change event - trigger real-time syncs.""" + product = self.db.query(Product).get(product_id) + store_id = product.store_id + + # Get enabled real-time sync configs + configs = self.db.query(InventorySyncConfig).filter( + InventorySyncConfig.store_id == store_id, + InventorySyncConfig.is_enabled == True, + InventorySyncConfig.sync_strategy == "realtime", + ).all() + + for config in configs: + adapter = self.adapters.get(config.marketplace) + if adapter: + await self._sync_single_product(adapter, config, product, new_quantity) + + async def run_scheduled_sync(self, store_id: int, marketplace: str): + """Run scheduled batch sync for a marketplace.""" + config = self._get_sync_config(store_id, marketplace) + adapter = self.adapters.get(marketplace) + + log = InventorySyncLog( + store_id=store_id, + marketplace=marketplace, + sync_type="full", + started_at=datetime.utcnow(), + status="running", + ) + self.db.add(log) + self.db.commit() + + try: + # Get all products for this store linked to this marketplace + products = self._get_products_for_sync(store_id, marketplace) + + # Build inventory update payload + inventory_data = [] + for product in products: + available_qty = self._calculate_available_quantity(product, config) + inventory_data.append({ + "sku": product.store_sku or product.marketplace_product.marketplace_product_id, + "quantity": available_qty, + }) + + # Sync via adapter + result = await adapter.sync_inventory_batch(config, inventory_data) + + log.completed_at = datetime.utcnow() + log.status = "success" if result.all_succeeded else "partial" + log.items_processed = len(inventory_data) + log.items_succeeded = result.succeeded_count + log.items_failed = result.failed_count + log.errors = result.errors + + config.last_sync_at = datetime.utcnow() + config.last_sync_status = log.status + config.items_synced_count = log.items_succeeded + + except Exception as e: + log.completed_at = datetime.utcnow() + log.status = "failed" + log.errors = [{"error": str(e)}] + config.last_sync_error = str(e) + + self.db.commit() + return log + + def _calculate_available_quantity( + self, + product: Product, + config: InventorySyncConfig, + ) -> int: + """Calculate quantity to report, applying safety stock.""" + inventory = product.inventory_entries[0] if product.inventory_entries else None + if not inventory: + return 0 + + available = inventory.quantity - inventory.reserved_quantity + available -= config.safety_stock # Apply safety buffer + + if available <= config.out_of_stock_threshold: + return 0 if config.sync_zero_stock else config.out_of_stock_threshold + + return max(0, available) +``` + +### 4.5 Letzshop Inventory Sync + +```python +class LetzshopInventorySyncAdapter: + """Sync inventory to Letzshop via CSV or GraphQL.""" + + async def sync_inventory_batch( + self, + config: InventorySyncConfig, + inventory_data: list[dict], + ) -> SyncResult: + """Sync inventory batch to Letzshop.""" + # Option 1: CSV Export (upload to SFTP/web location) + if config.api_credentials.get("method") == "csv": + return await self._sync_via_csv(config, inventory_data) + + # Option 2: GraphQL mutations + return await self._sync_via_graphql(config, inventory_data) + + async def _sync_via_csv( + self, + config: InventorySyncConfig, + inventory_data: list[dict], + ) -> SyncResult: + """Generate and upload CSV inventory file.""" + # Generate CSV + csv_content = self._generate_inventory_csv(inventory_data) + + # Upload to configured location (SFTP, S3, etc.) + upload_location = config.api_credentials.get("upload_url") + # ... upload logic + + return SyncResult(success=True, succeeded_count=len(inventory_data)) + + async def _sync_via_graphql( + self, + config: InventorySyncConfig, + inventory_data: list[dict], + ) -> SyncResult: + """Update inventory via GraphQL mutations.""" + mutation = """ + mutation UpdateInventory($input: InventoryUpdateInput!) { + updateInventory(input: $input) { + success + errors { sku, message } + } + } + """ + # ... execute mutation +``` + +--- + +## Part 5: Scheduler & Job Management + +### 5.1 Scheduled Jobs Overview + +| Job | Default Schedule | Configurable | Description | +|-----|------------------|--------------|-------------| +| `order_import_{marketplace}` | Every 5 min | Per store | Poll orders from marketplace | +| `inventory_sync_{marketplace}` | Every 15 min | Per store | Sync inventory to marketplace | +| `codeswholesale_catalog_sync` | Every 6 hours | Global | Update digital product catalog | +| `product_price_sync` | Daily | Per store | Sync price changes to marketplace | +| `sync_retry_failed` | Every 10 min | Global | Retry failed sync jobs | + +### 5.2 Job Configuration Model + +```python +class ScheduledJob(Base, TimestampMixin): + """Configurable scheduled job.""" + __tablename__ = "scheduled_jobs" + + id = Column(Integer, primary_key=True) + store_id = Column(Integer, ForeignKey("stores.id"), nullable=True) # Null = global + + job_type = Column(String, nullable=False) # 'order_import', 'inventory_sync', etc. + marketplace = Column(String) # Relevant marketplace if applicable + + # === SCHEDULE === + is_enabled = Column(Boolean, default=True) + cron_expression = Column(String) # Cron format + interval_minutes = Column(Integer) # Simple interval alternative + + # === EXECUTION === + last_run_at = Column(DateTime) + last_run_status = Column(String) + last_run_duration_ms = Column(Integer) + next_run_at = Column(DateTime) + + # === RETRY CONFIG === + max_retries = Column(Integer, default=3) + retry_delay_seconds = Column(Integer, default=60) + + __table_args__ = ( + UniqueConstraint("store_id", "job_type", "marketplace", name="uq_scheduled_job"), + ) +``` + +--- + +## Implementation Roadmap + +### Phase 1: Product Import (Weeks 1-2) + +**Goal:** Multi-marketplace product import with translations and digital product support. + +| Task | Priority | Status | +|------|----------|--------| +| Add product_type, is_digital fields to marketplace_products | High | [ ] | +| Create marketplace_product_translations table | High | [ ] | +| Create product_translations table | High | [ ] | +| Implement BaseMarketplaceImporter pattern | High | [ ] | +| Refactor LetzshopImporter from CSV processor | High | [ ] | +| Add CodesWholesale catalog importer | High | [ ] | +| Implement store override pattern on products | Medium | [ ] | +| Add translation override support | Medium | [ ] | +| Update API endpoints for translations | Medium | [ ] | + +**Detailed tasks:** See [Multi-Marketplace Product Architecture](../../development/migration/multi-marketplace-product-architecture.md) + +### Phase 2: Order Import (Weeks 3-4) + +**Goal:** Unified order management with multi-channel support. + +| Task | Priority | Status | +|------|----------|--------| +| Design unified orders schema | High | [ ] | +| Create orders, order_items, order_status_history tables | High | [ ] | +| Implement BaseOrderImporter pattern | High | [ ] | +| Implement LetzshopOrderImporter (GraphQL) | High | [ ] | +| Create order polling scheduler | High | [ ] | +| Build order list/detail store UI | Medium | [ ] | +| Add order notifications | Medium | [ ] | +| Implement order search and filtering | Medium | [ ] | + +### Phase 3: Order Fulfillment Sync (Weeks 5-6) + +**Goal:** Sync fulfillment status back to marketplaces, handle digital delivery. + +| Task | Priority | Status | +|------|----------|--------| +| Implement FulfillmentService | High | [ ] | +| Implement DigitalFulfillmentService | High | [ ] | +| Integrate CodesWholesale key purchase API | High | [ ] | +| Create fulfillment sync queue | High | [ ] | +| Implement LetzshopFulfillmentSync | High | [ ] | +| Build fulfillment UI (mark shipped, add tracking) | Medium | [ ] | +| Digital fulfillment email templates | Medium | [ ] | +| Fulfillment retry logic | Medium | [ ] | + +### Phase 4: Inventory Sync (Weeks 7-8) + +**Goal:** Real-time and scheduled inventory sync to marketplaces. + +| Task | Priority | Status | +|------|----------|--------| +| Create inventory_sync_configs table | High | [ ] | +| Create inventory_sync_logs table | High | [ ] | +| Implement InventorySyncService | High | [ ] | +| Implement LetzshopInventorySyncAdapter | High | [ ] | +| Create inventory change event system | High | [ ] | +| Build sync configuration UI | Medium | [ ] | +| Add sync status dashboard | Medium | [ ] | +| Implement real-time sync for future marketplaces | Low | [ ] | + +--- + +## Security Considerations + +### Credential Storage + +```python +# All marketplace credentials should be encrypted at rest +class MarketplaceCredential(Base, TimestampMixin): + """Encrypted marketplace credentials.""" + __tablename__ = "marketplace_credentials" + + id = Column(Integer, primary_key=True) + store_id = Column(Integer, ForeignKey("stores.id"), nullable=False) + marketplace = Column(String, nullable=False) + + # Encrypted using application-level encryption + credentials_encrypted = Column(LargeBinary, nullable=False) + + # Metadata (not sensitive) + credential_type = Column(String) # 'api_key', 'oauth', 'basic' + expires_at = Column(DateTime) + is_valid = Column(Boolean, default=True) + last_validated_at = Column(DateTime) +``` + +### API Rate Limiting + +- Respect marketplace API rate limits +- Implement exponential backoff for failures +- Queue operations to smooth out request bursts + +### Data Privacy + +- Customer data from marketplaces should follow GDPR guidelines +- Implement data retention policies +- Provide data export/deletion capabilities + +--- + +## Monitoring & Observability + +### Key Metrics + +| Metric | Description | Alert Threshold | +|--------|-------------|-----------------| +| `order_import_lag_seconds` | Time since last successful order poll | > 15 min | +| `inventory_sync_lag_seconds` | Time since last inventory sync | > 30 min | +| `fulfillment_sync_queue_depth` | Pending fulfillment syncs | > 100 | +| `sync_error_rate` | Failed syncs / total syncs | > 5% | +| `digital_fulfillment_success_rate` | Successful key retrievals | < 95% | + +### Health Checks + +```python +@router.get("/health/marketplace-integrations") +async def check_marketplace_health(store_id: int): + """Check health of marketplace integrations.""" + return { + "letzshop": { + "order_import": check_last_sync("letzshop", "order_import"), + "inventory_sync": check_last_sync("letzshop", "inventory_sync"), + }, + "codeswholesale": { + "catalog_sync": check_last_sync("codeswholesale", "catalog"), + "api_status": await check_codeswholesale_api(), + } + } +``` + +--- + +## Appendix A: CodesWholesale Integration Details + +### API Endpoints Used + +| Endpoint | Purpose | Frequency | +|----------|---------|-----------| +| `GET /products` | Fetch full catalog | Every 6 hours | +| `GET /products/{id}` | Get single product details | On-demand | +| `POST /orders` | Purchase license key | On order fulfillment | +| `GET /orders/{id}` | Check order status | After purchase | +| `GET /account/balance` | Check account balance | Periodically | + +### Product Catalog Mapping + +```python +def map_codeswholesale_product(cw_product: dict) -> dict: + """Map CodesWholesale product to marketplace_product format.""" + return { + "marketplace_product_id": f"cw_{cw_product['productId']}", + "marketplace": "codeswholesale", + "gtin": cw_product.get("ean"), + "product_type": "digital", + "is_digital": True, + "digital_delivery_method": "license_key", + "platform": cw_product.get("platform", "").lower(), # steam, origin, etc. + "region_restrictions": cw_product.get("regions"), + "price": cw_product["prices"][0]["value"], # Supplier cost + "currency": cw_product["prices"][0]["currency"], + "availability": "in_stock" if cw_product["quantity"] > 0 else "out_of_stock", + "attributes": { + "languages": cw_product.get("languages"), + "release_date": cw_product.get("releaseDate"), + } + } +``` + +--- + +## Appendix B: Status Mapping Reference + +### Order Status Mapping + +| Orion Status | Letzshop | Amazon | eBay | +|-----------------|----------|--------|------| +| PENDING | PENDING | Pending | - | +| CONFIRMED | PAID | Unshipped | Paid | +| PROCESSING | PROCESSING | - | - | +| SHIPPED | SHIPPED | Shipped | Shipped | +| DELIVERED | DELIVERED | - | Delivered | +| FULFILLED | - | - | - | +| CANCELLED | CANCELLED | Cancelled | Cancelled | +| REFUNDED | REFUNDED | Refunded | Refunded | + +--- + +## Related Documents + +- [Multi-Marketplace Product Architecture](../../development/migration/multi-marketplace-product-architecture.md) - Detailed product data model +- [Store Contact Inheritance](../../development/migration/store-contact-inheritance.md) - Override pattern reference +- [Database Migrations](../../development/migration/database-migrations.md) - Migration guidelines diff --git a/app/modules/marketplace/docs/data-model.md b/app/modules/marketplace/docs/data-model.md new file mode 100644 index 00000000..1cb8cc4b --- /dev/null +++ b/app/modules/marketplace/docs/data-model.md @@ -0,0 +1,297 @@ +# Marketplace Data Model + +Entity relationships and database schema for the marketplace module. + +## Entity Relationship Diagram + +``` +┌──────────────────────────┐ +│ Store │ (from tenancy module) +└──────┬───────────────────┘ + │ 1 + │ + ┌────┴──────────────────────────────────────────────┐ + │ │ │ │ + ▼ 1 ▼ * ▼ 0..1 ▼ * +┌──────────────┐ ┌──────────────┐ ┌───────────────┐ ┌──────────────┐ +│ Marketplace │ │ Marketplace │ │ StoreLetzshop │ │ Letzshop │ +│ ImportJob │ │ Product │ │ Credentials │ │ Order │ +│ │ │ │ │ │ │ │ +│ source_url │ │ gtin, mpn │ │ api_key_enc │ │ letzshop_id │ +│ language │ │ sku, brand │ │ auto_sync │ │ sync_status │ +│ status │ │ price_cents │ │ last_sync_at │ │ customer │ +│ imported_cnt │ │ is_digital │ │ default_ │ │ total_amount │ +│ error_count │ │ marketplace │ │ carrier │ │ tracking │ +└──────┬───────┘ └──────┬───────┘ └───────────────┘ └──────┬───────┘ + │ │ │ + ▼ * ▼ * │ +┌──────────────┐ ┌──────────────────┐ │ +│ Marketplace │ │ Marketplace │ │ +│ ImportError │ │ Product │ │ +│ │ │ Translation │ │ +│ row_number │ │ │ │ +│ error_type │ │ language │ │ +│ error_msg │ │ title │ │ +│ row_data │ │ description │ │ +└──────────────┘ │ meta_title │ │ + └──────────────────┘ │ + │ + ┌───────────────────────────────────────────────────────────┘ + │ + ▼ * ▼ * ▼ * +┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ +│ Letzshop │ │ Letzshop │ │ Letzshop │ +│ FulfillmentQueue │ │ SyncLog │ │ Historical │ +│ │ │ │ │ ImportJob │ +│ operation │ │ operation_type │ │ │ +│ payload │ │ direction │ │ current_phase │ +│ status │ │ status │ │ current_page │ +│ attempts │ │ records_* │ │ orders_processed │ +└──────────────────┘ └──────────────────┘ └──────────────────┘ + +┌──────────────────┐ ┌──────────────────┐ +│ Letzshop │ │ Store │ +│ StoreCache │ │ Onboarding │ +│ │ │ │ +│ letzshop_id │ │ status │ +│ name, slug │ │ current_step │ +│ claimed_by_ │ │ step_*_completed │ +│ store_id │ │ skipped_by_admin │ +└──────────────────┘ └──────────────────┘ +``` + +## Models + +### Product Import Pipeline + +#### MarketplaceProduct + +Canonical product data imported from marketplace sources. Serves as a staging area — stores publish selected products to their own catalog. + +| Field | Type | Description | +|-------|------|-------------| +| `marketplace_product_id` | String (unique) | Unique ID from marketplace | +| `marketplace` | String | Source marketplace (e.g., "Letzshop") | +| `gtin` | String | EAN/UPC barcode | +| `mpn` | String | Manufacturer Part Number | +| `sku` | String | Merchant's internal SKU | +| `store_name` | String | Store name from marketplace | +| `source_url` | String | CSV source URL | +| `product_type_enum` | Enum | physical, digital, service, subscription | +| `is_digital` | Boolean | Whether product is digital | +| `digital_delivery_method` | Enum | download, email, in_app, streaming, license_key | +| `brand` | String | Brand name | +| `google_product_category` | String | Google Shopping category | +| `condition` | String | new, used, refurbished | +| `price_cents` | Integer | Price in cents | +| `sale_price_cents` | Integer | Sale price in cents | +| `currency` | String | Currency code (default EUR) | +| `tax_rate_percent` | Decimal | VAT rate (default 17%) | +| `image_link` | String | Main product image | +| `additional_images` | JSON | Array of additional image URLs | +| `attributes` | JSON | Flexible attributes | +| `weight_grams` | Integer | Product weight | +| `is_active` | Boolean | Whether product is active | + +**Relationships:** `translations` (MarketplaceProductTranslation), `store_products` (Product) + +#### MarketplaceProductTranslation + +Localized content for marketplace products. + +| Field | Type | Description | +|-------|------|-------------| +| `marketplace_product_id` | FK | Parent product | +| `language` | String | Language code (en, fr, de, lb) | +| `title` | String | Product title | +| `description` | Text | Full description | +| `short_description` | Text | Short description | +| `meta_title` | String | SEO title | +| `meta_description` | String | SEO description | +| `url_slug` | String | URL-friendly slug | +| `source_import_id` | Integer | Import job that created this | +| `source_file` | String | Source CSV file | + +**Constraint:** Unique (`marketplace_product_id`, `language`) + +#### MarketplaceImportJob + +Tracks CSV product import jobs with progress and metrics. + +| Field | Type | Description | +|-------|------|-------------| +| `store_id` | FK | Target store | +| `user_id` | FK | Who triggered the import | +| `marketplace` | String | Source marketplace (default "Letzshop") | +| `source_url` | String | CSV feed URL | +| `language` | String | Import language for translations | +| `status` | String | pending, processing, completed, failed, completed_with_errors | +| `imported_count` | Integer | New products imported | +| `updated_count` | Integer | Existing products updated | +| `error_count` | Integer | Failed rows | +| `total_processed` | Integer | Total rows processed | +| `error_message` | Text | Error message if failed | +| `celery_task_id` | String | Background task ID | +| `started_at` | DateTime | Processing start time | +| `completed_at` | DateTime | Processing end time | + +**Relationships:** `store`, `user`, `errors` (MarketplaceImportError, cascade delete) + +#### MarketplaceImportError + +Detailed error records for individual import failures. + +| Field | Type | Description | +|-------|------|-------------| +| `import_job_id` | FK | Parent import job (cascade delete) | +| `row_number` | Integer | Row in source CSV | +| `identifier` | String | Product identifier (ID, GTIN, etc.) | +| `error_type` | String | missing_title, missing_id, parse_error, validation_error | +| `error_message` | String | Human-readable error | +| `row_data` | JSON | Snapshot of key fields from failing row | + +### Letzshop Order Integration + +#### StoreLetzshopCredentials + +Encrypted API credentials and sync settings per store. + +| Field | Type | Description | +|-------|------|-------------| +| `store_id` | FK (unique) | One credential set per store | +| `api_key_encrypted` | String | Fernet-encrypted API key | +| `api_endpoint` | String | GraphQL endpoint URL | +| `auto_sync_enabled` | Boolean | Enable automatic order sync | +| `sync_interval_minutes` | Integer | Auto-sync interval (5-1440) | +| `test_mode_enabled` | Boolean | Test mode flag | +| `default_carrier` | String | Default shipping carrier | +| `carrier_*_label_url` | String | Per-carrier label URL prefixes | +| `last_sync_at` | DateTime | Last sync timestamp | +| `last_sync_status` | String | success, failed, partial | +| `last_sync_error` | Text | Error message if failed | + +#### LetzshopOrder + +Tracks orders imported from Letzshop marketplace. + +| Field | Type | Description | +|-------|------|-------------| +| `store_id` | FK | Store that owns this order | +| `letzshop_order_id` | String | Letzshop order GID | +| `letzshop_shipment_id` | String | Letzshop shipment GID | +| `letzshop_order_number` | String | Human-readable order number | +| `external_order_number` | String | Customer-facing reference | +| `shipment_number` | String | Carrier shipment number | +| `local_order_id` | FK | Linked local order (if any) | +| `letzshop_state` | String | Current Letzshop state | +| `customer_email` | String | Customer email | +| `customer_name` | String | Customer name | +| `customer_locale` | String | Customer language for invoicing | +| `total_amount` | String | Order total | +| `currency` | String | Currency code | +| `raw_order_data` | JSON | Full order data from Letzshop | +| `inventory_units` | JSON | List of inventory units | +| `sync_status` | String | pending, confirmed, rejected, shipped | +| `shipping_carrier` | String | Carrier code | +| `tracking_number` | String | Tracking number | +| `tracking_url` | String | Full tracking URL | +| `shipping_country_iso` | String | Shipping country | +| `billing_country_iso` | String | Billing country | +| `order_date` | DateTime | Original order date from Letzshop | + +#### LetzshopFulfillmentQueue + +Outbound operation queue with retry logic for Letzshop fulfillment. + +| Field | Type | Description | +|-------|------|-------------| +| `store_id` | FK | Store | +| `order_id` | FK | Linked order | +| `operation` | String | confirm_item, decline_item, set_tracking | +| `payload` | JSON | Operation data | +| `status` | String | pending, processing, completed, failed | +| `attempts` | Integer | Retry count | +| `max_attempts` | Integer | Max retries (default 3) | +| `error_message` | Text | Last error | +| `response_data` | JSON | Response from Letzshop | + +#### LetzshopSyncLog + +Audit trail for all Letzshop sync operations. + +| Field | Type | Description | +|-------|------|-------------| +| `store_id` | FK | Store | +| `operation_type` | String | order_import, confirm_inventory, set_tracking, etc. | +| `direction` | String | inbound, outbound | +| `status` | String | success, failed, partial | +| `records_processed` | Integer | Total records | +| `records_succeeded` | Integer | Successful records | +| `records_failed` | Integer | Failed records | +| `error_details` | JSON | Detailed error info | +| `started_at` | DateTime | Operation start | +| `completed_at` | DateTime | Operation end | +| `duration_seconds` | Integer | Total duration | +| `triggered_by` | String | user_id, scheduler, webhook | + +#### LetzshopHistoricalImportJob + +Tracks progress of historical order imports for real-time progress polling. + +| Field | Type | Description | +|-------|------|-------------| +| `store_id` | FK | Store | +| `user_id` | FK | Who triggered | +| `status` | String | pending, fetching, processing, completed, failed | +| `current_phase` | String | confirmed, declined | +| `current_page` | Integer | Current pagination page | +| `total_pages` | Integer | Total pages | +| `shipments_fetched` | Integer | Shipments fetched so far | +| `orders_processed` | Integer | Orders processed | +| `orders_imported` | Integer | New orders imported | +| `orders_updated` | Integer | Updated existing orders | +| `orders_skipped` | Integer | Duplicate orders skipped | +| `products_matched` | Integer | Products matched by EAN | +| `products_not_found` | Integer | Products not found | +| `confirmed_stats` | JSON | Stats for confirmed phase | +| `declined_stats` | JSON | Stats for declined phase | +| `celery_task_id` | String | Background task ID | + +### Supporting Models + +#### LetzshopStoreCache + +Cache of Letzshop marketplace store directory for browsing and claiming during signup. + +| Field | Type | Description | +|-------|------|-------------| +| `letzshop_id` | String (unique) | Letzshop store identifier | +| `slug` | String | URL slug | +| `name` | String | Store name | +| `merchant_name` | String | Merchant name | +| `is_active` | Boolean | Active on Letzshop | +| `description_en/fr/de` | Text | Localized descriptions | +| `email`, `phone`, `website` | String | Contact info | +| `street`, `zipcode`, `city`, `country_iso` | String | Address | +| `latitude`, `longitude` | Float | Geo coordinates | +| `categories` | JSON | Category array | +| `social_media_links` | JSON | Social links | +| `claimed_by_store_id` | FK | Store that claimed this listing | +| `claimed_at` | DateTime | When claimed | +| `last_synced_at` | DateTime | Last directory sync | + +#### StoreOnboarding + +Tracks completion of mandatory onboarding steps for new stores. + +| Field | Type | Description | +|-------|------|-------------| +| `store_id` | FK (unique) | One record per store | +| `status` | String | not_started, in_progress, completed, skipped | +| `current_step` | Integer | Current onboarding step | +| Step 1 | — | Merchant profile completion | +| Step 2 | — | Letzshop API key setup + verification | +| Step 3 | — | Product import (CSV URL set) | +| Step 4 | — | Order sync (first sync job) | +| `skipped_by_admin` | Boolean | Admin override | +| `skipped_reason` | Text | Reason for skip | diff --git a/app/modules/marketplace/docs/import-improvements.md b/app/modules/marketplace/docs/import-improvements.md new file mode 100644 index 00000000..827303d8 --- /dev/null +++ b/app/modules/marketplace/docs/import-improvements.md @@ -0,0 +1,601 @@ +# Letzshop Order Import - Improvement Plan + +## Current Status (2025-12-17) + +### Schema Discovery Complete ✅ + +After running GraphQL introspection queries, we have identified all available fields. + +### Available Fields Summary + +| Data | GraphQL Path | Notes | +|------|-------------|-------| +| **EAN/GTIN** | `variant.tradeId.number` | The product barcode | +| **Trade ID Type** | `variant.tradeId.parser` | Format: gtin13, gtin14, gtin12, gtin8, isbn13, isbn10 | +| **Brand Name** | `product._brand { ... on Brand { name } }` | Union type requires fragment | +| **MPN** | `variant.mpn` | Manufacturer Part Number | +| **SKU** | `variant.sku` | Merchant's internal SKU | +| **Product Name** | `variant.product.name { en, fr, de }` | Translated names | +| **Price** | `variant.price` | Unit price | +| **Quantity** | Count of `inventoryUnits` | Each unit = 1 item | +| **Customer Language** | `order.locale` | Language for invoice (en, fr, de) | +| **Customer Country** | `order.shipAddress.country` | Country object | + +### Key Findings + +1. **EAN lives in `tradeId`** - Not a direct field on Variant, but nested in `tradeId.number` +2. **TradeIdParser enum values**: `gtin14`, `gtin13` (EAN-13), `gtin12` (UPC), `gtin8`, `isbn13`, `isbn10` +3. **Brand is a Union** - Must use `... on Brand { name }` fragment, also handles `BrandUnknown` +4. **No quantity field** - Each InventoryUnit represents 1 item; count units to get quantity + +## Updated GraphQL Query + +```graphql +query { + shipments(state: unconfirmed) { + nodes { + id + number + state + order { + id + number + email + total + completedAt + locale + shipAddress { + firstName + lastName + merchant + streetName + streetNumber + city + zipCode + phone + country { + name + iso + } + } + billAddress { + firstName + lastName + merchant + streetName + streetNumber + city + zipCode + phone + country { + name + iso + } + } + } + inventoryUnits { + id + state + variant { + id + sku + mpn + price + tradeId { + number + parser + } + product { + name { en fr de } + _brand { + ... on Brand { name } + } + } + } + } + tracking { + code + provider + } + } + } +} +``` + +## Implementation Steps + +### Step 1: Update GraphQL Queries ✅ DONE +Update in `app/services/letzshop/client_service.py`: +- `QUERY_SHIPMENTS_UNCONFIRMED` ✅ +- `QUERY_SHIPMENTS_CONFIRMED` ✅ +- `QUERY_SHIPMENT_BY_ID` ✅ +- `QUERY_SHIPMENTS_PAGINATED_TEMPLATE` ✅ (new - for historical import) + +### Step 2: Update Order Service ✅ DONE +Updated `create_order()` and `update_order_from_shipment()` in `app/services/letzshop/order_service.py`: +- Extract `tradeId.number` as EAN ✅ +- Store MPN if available ✅ +- Store `locale` for invoice language ✅ +- Store shipping/billing country ISO codes ✅ +- Enrich inventory_units with EAN, MPN, SKU, product_name ✅ + +**Database changes:** +- Added `customer_locale` column to `LetzshopOrder` +- Added `shipping_country_iso` column to `LetzshopOrder` +- Added `billing_country_iso` column to `LetzshopOrder` +- Migration: `a9a86cef6cca_add_letzshop_order_locale_and_country_.py` + +### Step 3: Match Products by EAN ✅ DONE (Basic) +When importing orders: +- Use `tradeId.number` (EAN) to find matching local product ✅ +- `_match_eans_to_products()` function added ✅ +- Returns match statistics (products_matched, products_not_found) ✅ + +**TODO for later:** +- ⬜ Decrease stock for matched product (needs careful implementation) +- ⬜ Show match status in order detail view + +### Step 4: Update Frontend ✅ DONE (Historical Import) +- Added "Import History" button to Orders tab ✅ +- Added historical import result display ✅ +- Added `importHistoricalOrders()` JavaScript function ✅ + +**TODO for later:** +- ⬜ Show product details in individual order view (EAN, MPN, SKU, match status) + +### Step 5: Historical Import Feature ✅ DONE +Import all confirmed orders for: +- Sales analytics (how many products sold) +- Customer records +- Historical data + +**Implementation:** +- Pagination support with `get_all_shipments_paginated()` ✅ +- Deduplication by `letzshop_order_id` ✅ +- EAN matching during import ✅ +- Progress callback for large imports ✅ + +**Endpoints Added:** +- `POST /api/v1/admin/letzshop/stores/{id}/import-history` - Import historical orders +- `GET /api/v1/admin/letzshop/stores/{id}/import-summary` - Get import statistics + +**Frontend:** +- "Import History" button in Orders tab +- Result display showing imported/updated/skipped counts + +**Tests:** +- Unit tests in `tests/unit/services/test_letzshop_service.py` ✅ +- Manual test script `scripts/test_historical_import.py` ✅ + +## Test Results (2025-12-17) + +### Query Test: PASSED ✅ + +``` +Example shipment: + Shipment #: H43748338602 + Order #: R702236251 + Customer: miriana.leal@letzshop.lu + Locale: fr <<<< LANGUAGE + Total: 32.88 EUR + + Ship to: Miriana Leal Ferreira + City: 1468 Luxembourg + Country: LU + + Items (1): + - Pocket POP! Keychains: Marvel Avengers Infinity War - Iron Spider + SKU: 00889698273022 + MPN: None + EAN: 00889698273022 (gtin14) <<<< BARCODE + Price: 5.88 EUR +``` + +### Known Issues / Letzshop API Bugs + +#### Bug 1: `_brand` field causes server error +- **Error**: `NoMethodError: undefined method 'demodulize' for nil` +- **Trigger**: Querying `_brand { ... on Brand { name } }` on some products +- **Workaround**: Removed `_brand` from queries +- **Status**: To report to Letzshop + +#### Bug 2: `tracking` field causes server error (ALL queries) +- **Error**: `NoMethodError: undefined method 'demodulize' for nil` +- **Trigger**: Including `tracking { code provider }` in ANY shipment query +- **Tested and FAILS on**: + - Paginated queries: `shipments(state: confirmed, first: 10) { nodes { tracking { code provider } } }` + - Non-paginated queries: `shipments(state: confirmed) { nodes { tracking { code provider } } }` + - Single shipment queries: Also fails (Letzshop doesn't support `node(id:)` interface) +- **Impact**: Cannot retrieve tracking numbers and carrier info at all +- **Workaround**: None - tracking info is currently unavailable via API +- **Status**: **CRITICAL - Must report to Letzshop** +- **Date discovered**: 2025-12-17 + +**Note**: Letzshop automatically creates tracking when orders are confirmed. The carrier picks up parcels. But we cannot retrieve this info due to the API bug. + +#### Bug 3: Product table missing `gtin` field ✅ FIXED +- **Error**: `type object 'Product' has no attribute 'gtin'` +- **Cause**: `gtin` field only existed on `MarketplaceProduct` (staging table), not on `Product` (operational table) +- **Date discovered**: 2025-12-17 +- **Date fixed**: 2025-12-18 +- **Fix applied**: + 1. Migration `cb88bc9b5f86_add_gtin_columns_to_product_table.py` adds: + - `gtin` (String(50)) - the barcode number + - `gtin_type` (String(20)) - the format type (gtin13, gtin14, etc.) + - Indexes: `idx_product_gtin`, `idx_product_store_gtin` + 2. `models/database/product.py` updated with new columns + 3. `_match_eans_to_products()` now queries `Product.gtin` + 4. `get_products_by_eans()` now returns products by EAN lookup +- **Status**: COMPLETE + +**GTIN Types Reference:** + +| Type | Digits | Common Name | Region/Use | +|------|--------|-------------|------------| +| gtin13 | 13 | EAN-13 | Europe (most common) | +| gtin12 | 12 | UPC-A | North America | +| gtin14 | 14 | ITF-14 | Logistics/cases | +| gtin8 | 8 | EAN-8 | Small items | +| isbn13 | 13 | ISBN-13 | Books | +| isbn10 | 10 | ISBN-10 | Books (legacy) | + +Letzshop API returns: +- `tradeId.number` → store in `gtin` +- `tradeId.parser` → store in `gtin_type` + +**Letzshop Shipment States (from official docs):** + +| Letzshop State | Our sync_status | Description | +|----------------|-----------------|-------------| +| `unconfirmed` | `pending` | New order, needs store confirmation | +| `confirmed` | `confirmed` | At least one product confirmed | +| `declined` | `rejected` | All products rejected | + +Note: There is no "shipped" state in Letzshop. Shipping is tracked via the `tracking` field (code + provider), not as a state change. + +--- + +## Historical Confirmed Orders Import + +### Purpose +Import all historical confirmed orders from Letzshop to: +1. **Sales Analytics** - Track total products sold, revenue by product/category +2. **Customer Records** - Build customer database with order history +3. **Inventory Reconciliation** - Understand what was sold to reconcile stock + +### Implementation Plan + +#### 1. Add "Import Historical Orders" Feature +- New endpoint: `POST /api/v1/admin/letzshop/stores/{id}/import-history` +- Parameters: + - `state`: confirmed/shipped/delivered (default: confirmed) + - `since`: Optional date filter (import orders after this date) + - `dry_run`: Preview without saving + +#### 2. Pagination Support +Letzshop likely returns paginated results. Need to handle: +```graphql +query { + shipments(state: confirmed, first: 50, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + nodes { ... } + } +} +``` + +#### 3. Deduplication +- Check if order already exists by `letzshop_order_id` before inserting +- Update existing orders if data changed + +#### 4. EAN Matching & Stock Adjustment +When importing historical orders: +- Match `tradeId.number` (EAN) to local products +- Calculate total quantity sold per product +- Option to adjust inventory based on historical sales + +#### 5. Customer Database +Extract and store customer data: +- Email (unique identifier) +- Name (from shipping address) +- Preferred language (from `order.locale`) +- Order count, total spent + +#### 6. UI: Historical Import Page +Admin interface to: +- Trigger historical import +- View import progress +- See summary: X orders imported, Y customers added, Z products matched + +### Data Flow + +``` +Letzshop API (confirmed shipments) + │ + ▼ +┌───────────────────────┐ +│ Import Service │ +│ - Fetch all pages │ +│ - Deduplicate │ +│ - Match EAN to SKU │ +└───────────────────────┘ + │ + ▼ +┌───────────────────────┐ +│ Database │ +│ - letzshop_orders │ +│ - customers │ +│ - inventory updates │ +└───────────────────────┘ + │ + ▼ +┌───────────────────────┐ +│ Analytics Dashboard │ +│ - Sales by product │ +│ - Revenue over time │ +│ - Customer insights │ +└───────────────────────┘ +``` + +--- + +## Schema Reference + +### Variant Fields +``` +baseAmount: String +baseAmountProduct: String +baseUnit: String +countOnHand: Int +id: ID! +images: [Image]! +inPresale: Boolean! +isMaster: Boolean! +mpn: String +price: Float! +priceCrossed: Float +pricePerUnit: Float +product: Product! +properties: [Property]! +releaseAt: Iso8601Time +sku: String +tradeId: TradeId +uniqueId: String +url: String! +``` + +### TradeId Fields +``` +isRestricted: Boolean +number: String! # <-- THE EAN/GTIN +parser: TradeIdParser! # <-- Format identifier +``` + +### TradeIdParser Enum +``` +gtin14 - GTIN-14 (14 digits) +gtin13 - GTIN-13 / EAN-13 (13 digits, most common in Europe) +gtin12 - GTIN-12 / UPC-A (12 digits, common in North America) +gtin8 - GTIN-8 / EAN-8 (8 digits) +isbn13 - ISBN-13 (books) +isbn10 - ISBN-10 (books) +``` + +### Brand (via BrandUnion) +``` +BrandUnion = Brand | BrandUnknown + +Brand fields: + id: ID! + name: String! + identifier: String! + descriptor: String + logo: Attachment + url: String! +``` + +### InventoryUnit Fields +``` +id: ID! +price: Float! +state: String! +taxRate: Float! +uniqueId: String +variant: Variant +``` + +## Reference: Letzshop Frontend Shows + +From the Letzshop merchant interface: +- Order number: R532332163 +- Shipment number: H74683403433 +- Product: "Pop! Rocks: DJ Khaled - DJ Khaled #237" +- Brand: Funko +- Internal merchant number: MH-FU-56757 +- Price: 16,95 € +- Quantity: 1 +- Shipping: 2,99 € +- Total: 19,94 € + +--- + +## Completed (2025-12-18) + +### Order Stats Fix ✅ +- **Issue**: Order status cards (Pending, Confirmed, etc.) were showing incorrect counts +- **Cause**: Stats were calculated client-side from only the visible page of orders +- **Fix**: + 1. Added `get_order_stats()` method to `LetzshopOrderService` + 2. Added `LetzshopOrderStats` schema with pending/confirmed/rejected/shipped counts + 3. API now returns `stats` field with counts for ALL orders + 4. JavaScript uses server-side stats instead of client-side calculation +- **Status**: COMPLETE + +### Tracking Investigation ✅ +- **Issue**: Letzshop API bug prevents querying tracking field +- **Added**: `--tracking` option to `letzshop_introspect.py` to investigate workarounds +- **Findings**: Bug is on Letzshop's side, no client-side workaround possible +- **Recommendation**: Store tracking info locally after setting via API + +### Item-Level Confirmation ✅ +- **Issue**: Orders were being confirmed/declined at order level, but Letzshop requires item-level actions +- **Letzshop Model**: + - Each `inventoryUnit` must be confirmed/declined individually via `confirmInventoryUnits` mutation + - `isAvailable: true` = confirmed, `isAvailable: false` = declined + - Inventory unit states: `unconfirmed` → `confirmed_available` / `confirmed_unavailable` / `returned` + - Shipment states derived from items: `unconfirmed` / `confirmed` / `declined` + - Partial confirmation allowed (some items confirmed, some declined) +- **Fix**: + 1. Order detail modal now shows each item with product details (name, EAN, SKU, MPN, price) + 2. Per-item confirm/decline buttons for pending items + 3. Admin API endpoints for single-item and bulk operations: + - `POST /stores/{id}/orders/{id}/items/{id}/confirm` + - `POST /stores/{id}/orders/{id}/items/{id}/decline` + 4. Order status automatically updates based on item states: + - All items declined → order status = "declined" + - Any item confirmed → order status = "confirmed" + +### Terminology Update ✅ +- Changed "Rejected" to "Declined" throughout UI to match Letzshop terminology +- Internal `sync_status` value remains "rejected" for backwards compatibility +- Filter dropdown, status badges, and action buttons now use "Declined" +- Added "Declined" stats card to orders dashboard + +### Historical Import: Multiple Phases ✅ +- Historical import now fetches both `confirmed` AND `unconfirmed` (pending) shipments +- Note: "declined" is NOT a valid Letzshop shipment state - declined items are tracked at inventory unit level +- Combined stats shown in import result + +--- + +## Completed (2025-12-19) + +### Historical Import Progress Bar ✅ +Real-time progress feedback for historical import using background tasks with database polling. + +**Implementation:** +- Background task (`app/tasks/letzshop_tasks.py`) runs historical import asynchronously +- Progress stored in `LetzshopHistoricalImportJob` database model +- Frontend polls status endpoint every 2 seconds +- Two-phase import: confirmed orders first, then unconfirmed (pending) orders + +**Backend:** +- `LetzshopHistoricalImportJob` model tracks: status, current_phase, current_page, shipments_fetched, orders_processed, confirmed_stats, declined_stats +- `POST /stores/{id}/import-history` starts background job, returns job_id immediately +- `GET /stores/{id}/import-history/{job_id}/status` returns current progress + +**Frontend:** +- Progress panel shows: phase (confirmed/pending), page number, shipments fetched, orders processed +- Disabled "Import History" button during import with spinner +- Final result summary shows combined stats from both phases + +**Key Discovery:** +- Letzshop API has NO "declined" shipment state +- Valid states: `awaiting_order_completion`, `unconfirmed`, `completed`, `accepted`, `confirmed` +- Declined items are tracked at inventory unit level with state `confirmed_unavailable` + +### Filter for Declined Items ✅ +Added ability to filter orders that have at least one declined/unavailable item. + +**Backend:** +- `list_orders()` accepts `has_declined_items: bool` parameter +- Uses JSON string contains check: `inventory_units.cast(String).contains("confirmed_unavailable")` +- `get_order_stats()` returns `has_declined_items` count + +**Frontend:** +- "Has Declined Items" toggle button in filters section +- Shows count badge when there are orders with declined items +- Toggles between all orders and filtered view + +**API:** +- `GET /stores/{id}/orders?has_declined_items=true` - filter orders + +### Order Date Display ✅ +Orders now display the actual order date from Letzshop instead of the import date. + +**Database:** +- Added `order_date` column to `LetzshopOrder` model +- Migration: `2362c2723a93_add_order_date_to_letzshop_orders.py` + +**Backend:** +- `create_order()` extracts `completedAt` from Letzshop order data and stores as `order_date` +- `update_order_from_shipment()` populates `order_date` if not already set +- Date parsing handles ISO format with timezone (including `Z` suffix) + +**Frontend:** +- Order table displays `order_date` with fallback to `created_at` for legacy orders +- Format: localized date/time string + +**Note:** Existing orders imported before this change will continue showing `created_at` until re-imported via historical import. + +### Search Filter ✅ +Added search functionality to find orders by order number, customer name, or email. + +**Backend:** +- `list_orders()` accepts `search: str` parameter +- Uses ILIKE for case-insensitive partial matching across: + - `letzshop_order_number` + - `customer_name` + - `customer_email` + +**Frontend:** +- Search input field with magnifying glass icon +- Debounced input (300ms) to avoid excessive API calls +- Clear button to reset search +- Resets to page 1 when search changes + +**API:** +- `GET /stores/{id}/orders?search=query` - search orders + +--- + +## Next Steps (TODO) + +### Priority 1: Stock Management +When an order is confirmed/imported: +1. Match EAN from order to local product catalog +2. Decrease stock quantity for matched products +3. Handle cases where product not found (alert/log) + +**Considerations:** +- Should stock decrease happen on import or only on confirmation? +- Need rollback mechanism if order is rejected +- Handle partial matches (some items found, some not) + +### Priority 2: Invoice Generation +Use `customer_locale` to generate invoices in customer's language: +- Invoice template with multi-language support +- PDF generation + +### Priority 3: Analytics Dashboard +Build sales analytics based on imported orders: +- Sales by product +- Sales by time period +- Customer statistics +- Revenue breakdown + +--- + +## Files Modified (2025-12-16 to 2025-12-19) + +| File | Changes | +|------|---------| +| `app/services/letzshop/client_service.py` | Added paginated query, updated all queries with EAN/locale/country | +| `app/services/letzshop/order_service.py` | Historical import, EAN matching, order stats, has_declined_items filter, search filter, order_date extraction | +| `models/database/letzshop.py` | Added locale/country/order_date columns, `LetzshopHistoricalImportJob` model | +| `models/database/product.py` | Added `gtin` and `gtin_type` columns for EAN matching | +| `models/schema/letzshop.py` | Added `LetzshopOrderStats`, `LetzshopHistoricalImportJobResponse`, `order_date` field | +| `app/api/v1/admin/letzshop.py` | Import-history endpoints, has_declined_items filter, search filter, order_date in response | +| `app/tasks/letzshop_tasks.py` | **NEW** - Background task for historical import with progress tracking | +| `app/templates/admin/partials/letzshop-orders-tab.html` | Import History button, progress panel, declined items filter, search input, order_date display | +| `static/admin/js/marketplace-letzshop.js` | Historical import polling, progress display, declined items filter, search functionality | +| `tests/unit/services/test_letzshop_service.py` | Added tests for new functionality | +| `scripts/test_historical_import.py` | Manual test script for historical import | +| `scripts/debug_historical_import.py` | **NEW** - Debug script for shipment states and declined items | +| `scripts/letzshop_introspect.py` | GraphQL schema introspection tool, tracking workaround tests | +| `alembic/versions/a9a86cef6cca_*.py` | Migration for locale/country columns | +| `alembic/versions/cb88bc9b5f86_*.py` | Migration for gtin columns on Product table | +| `alembic/versions/*_add_historical_import_jobs.py` | **NEW** - Migration for LetzshopHistoricalImportJob table | +| `alembic/versions/2362c2723a93_*.py` | **NEW** - Migration for order_date column | diff --git a/app/modules/marketplace/docs/index.md b/app/modules/marketplace/docs/index.md new file mode 100644 index 00000000..0e02475a --- /dev/null +++ b/app/modules/marketplace/docs/index.md @@ -0,0 +1,73 @@ +# Marketplace (Letzshop) + +Letzshop marketplace integration for product sync, order import, and catalog synchronization. + +## Overview + +| Aspect | Detail | +|--------|--------| +| Code | `marketplace` | +| Classification | Optional | +| Dependencies | `inventory`, `catalog`, `orders` | +| Status | Active | + +## Features + +- `letzshop_sync` — Letzshop API synchronization +- `marketplace_import` — Product and order import +- `product_sync` — Bidirectional product sync +- `order_import` — Marketplace order import +- `marketplace_analytics` — Marketplace performance metrics + +## Permissions + +| Permission | Description | +|------------|-------------| +| `marketplace.view_integration` | View marketplace integration | +| `marketplace.manage_integration` | Manage marketplace settings | +| `marketplace.sync_products` | Trigger product sync | + +## Data Model + +See [Data Model](data-model.md) for full entity relationships. + +- **MarketplaceProduct** — Canonical product data from marketplace sources +- **MarketplaceProductTranslation** — Localized product content (title, description) +- **MarketplaceImportJob** — CSV import job tracking with metrics +- **MarketplaceImportError** — Detailed error records per import +- **StoreLetzshopCredentials** — Encrypted API keys and sync settings +- **LetzshopOrder** — Imported orders from Letzshop +- **LetzshopFulfillmentQueue** — Outbound operation queue with retry logic +- **LetzshopSyncLog** — Audit trail for sync operations +- **LetzshopHistoricalImportJob** — Historical order import progress +- **LetzshopStoreCache** — Marketplace store directory cache +- **StoreOnboarding** — Store onboarding step tracking + +## API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `*` | `/api/v1/admin/marketplace/*` | Admin marketplace management | +| `*` | `/api/v1/admin/letzshop/*` | Letzshop-specific endpoints | +| `*` | `/api/v1/admin/marketplace-products/*` | Product mapping management | + +## Scheduled Tasks + +| Task | Schedule | Description | +|------|----------|-------------| +| `marketplace.sync_store_directory` | Daily 02:00 | Sync store directory from Letzshop | + +## Configuration + +Letzshop API credentials are configured per-store via the admin UI. + +## Additional Documentation + +- [Data Model](data-model.md) — Entity relationships and database schema +- [Architecture](architecture.md) — Multi-marketplace integration architecture +- [Integration Guide](integration-guide.md) — CSV import guide with store/admin interfaces +- [API Reference](api.md) — Letzshop GraphQL API reference +- [Order Integration](order-integration.md) — Bidirectional order management with Letzshop +- [Admin Guide](admin-guide.md) — Admin portal management guide +- [Import Improvements](import-improvements.md) — GraphQL field mapping and EAN matching +- [Job Queue](job-queue.md) — Job queue improvements and table harmonization diff --git a/app/modules/marketplace/docs/integration-guide.md b/app/modules/marketplace/docs/integration-guide.md new file mode 100644 index 0000000000000000000000000000000000000000..7e784353525ab5dba9ef66957bcf2d9c3b7e828b GIT binary patch literal 19288 zcmdU1OLH4ncAoMkRj5==7FlGG#Vsmjg9yNfWQ}De%#=imHZ^)MB;{EsE})6N0NVn( z;qC?{j^$le$tE?s?6S%tyZnXGAItZhbMNho2W7gfnyP4Fqr30(-1GR(&_i>o?J|cy2UuQL1Q z+xDHp{oLgHhvo1xtL?C!mlofW=uy6()kTTb%erI}bk1!^meCzaQ2NES({c{hU;)$tNDyvQ|U2!|sI>X;vTGja<4IQG$5*zTCON6m>5%+m5CA9OhzX*g*PE=qG*z9{^UBmE z8sW9;P!BH`+meMRNr_oyr5UIzO}{FNF?jL}96Td)A7KyN@ieJ1gIm9}JhjQBS7%de zrbV8C#dHw~R%Oc)!W(I>n>o0%xOaA>;>l3r)-%%-YnKsf?t!c$qpS1q=m(2gGzvBW_fKR|6 z$t0QwUxY5^%uYtVGb#sYDTCK1PMdIkJZ_7iZ~rhXQv2g}7=d3znzv)dX8&j5;H3Y; z=g?hdik_OE&A@mkBQ)O@f1%~te|WE2fpq;SG+a9k^pts;T-$Q(*YNVAEMOa2aQkZ4 zokcS?4?(}^S%fdmkL5$y?lj+cT+#V>#r$1yQ8mpz$_Ao2^=G7RphYRVR19nXeUt@I zA-Rd5MYwTiPo5MPFw{9zrM`YRuZv!3MEf^hl2Iplq2K_soJ@c}{Ib=}%SyID* zsvw)IEHN*STX)%qt2!vHKq>Zcx_ii1=rLHgjREY&uqe~kJ&v%J_c#=pBrV?LlOjoJ zQW{%`CqSBu1SStQ?Q@Re$r!ICp4@%+Q&&b;m~PK!DOQ@c?sJqgwX84Y9jx@8;r^Vx zbuBqHcBh81^&9O*Sat#c14Ax05`-WktISMaHV9HtVpf_V7;aLG8$+nJ!5sn-s$jTQ ztJ8UT`jl%Kc40+q zz}f2nu&=Ua1rk9yC@PY^5ihZm=!VMH+{K1Rfg95D5n02WWq%Mlc=QQu?s5DKixmDb z?cJT7-u<1O9Y7M<;y=%`;cIi0O(q!X1gP4BRqVoT*ZBcLM1vI&u@YOo$kBSj?z3Vx zhvlY!K^SHeEXRXDG?b$jCSDw4uVqZn4B&@|gV7&WSN(Z8>5t064PR`2)0fn$n1@6A zI^$&5+*LBpa&o-^!`AdpWEr0Y3I7f@$Fy_%#^UoRE32BYw1I=G2o&-)2Afo7YU?r^ zqPHpwn|6&8oUVdK@gEMIF90c+3>~FNL5f>y>m-|Cq#^}KBH~rD@S8siC2c!m{%)Xe z*Hp#41bza~Z3@}2IB(PZ@~BOSY_XH+tddrp&SL?!bj(!!eS=?aIaE37b_w#W6p5Dw zzC(_67Nx2`bA{fDlSyVTLc+GEYSM(LV%(rAEIh$$v%3S1!ZuS(acOfP={mczMoz@O z#W?80A%NDi;i76H0iPFUMn|KxRGawU_w%fTdJ~2Jvce}(_Ro{6Yzz*d?7&HB%#o9D zAak{eqw2=4lUBeTV_H;(|@lgLa}&7sW)Tg2)px5H#v1Xh^Ie z$*+}%oN8^134Po%T1dwt&JG-w#f&DOY*~qch{j+a&w9b$04&a-CEz7Ob}%K_efYXJ zg7(%}jbq_PM+cbWIEOp|SNKh3ihOdda^|)JnZLL;!+8lts*PZJGlxBen0bzWD1I)- zc$(#}xom0=lVExm4{-Ew6ab(y2T4VH+VjQ~TJEszM6d%oV zF`CIFtEtIpaf;CheuI#~Pu4SfPP+R9a31U{!XPFN#_jeJqP|STu0J6m+Wz(uBJCF? zL|;Wyu1Lro18o$1EffDsj&TAxOX(4ppOrYc{``Eq9a&h zD~HryZt6kxFH zF36=)sasUwqgw7bg#yk-VC=GhnfK5~94zv9QiJbu?(!kP#5ItG8v`(3NKMrTl4)G= z63!cPyN4AV3`3id3+7CLZVMtN_+PUj!0n*Yzd1SQHvS8$1Tjp(iiS&ds*2 zs`aQyulLLkfXT*eC_1NKv$1D3F6(+$?QL({w*cZ3y9GV8?JeRK{GW}k_KKFmZo%Jk zp9NL+i7N3GF5OP+$JVK>5J0XhW;PoTyndGQ-9abaM$!)EZHe*Hd_A~uElKF@2e=l+h{gY%QZm%I1f2hn8BYxlOM zspvZeZ)Iy=e1UCa7f&I33POmn;Ma-1jyLmLLel-+Pbb+ltM_+zVq&>H0VG#g->a>y zt#9Kg@h&Wxqi_%IyNt$Xl3(IXoeuO0{1}+JU_VuLJ^B4P@DfR39b*bvxrTM+2iQytyo*nZ3)&YlP|G1Nv^Es&+q>Q$2+%MS$-X>8!!URmurY8 z6`8&sSCsPFRuGjyI+@c`|~&vqOL4Y!R6>` zb#!JvSF7WR;0PCc$@4k@ob5Q-i50*M8s2j+!?N-2^9R1O!Vhx&-4AiSvgxh4euZNf zxZe8?C#qj0|EkIz&fT%k4@=yZC_`kC$fZehsgSprH}NXnv{&6>r@;w$R=vYmh!|;s za&HM-vT?Rum-7Ui)7Ik;h|c#pSBN8~%4I#zlj^l;yHi+jmQ@ga*Heq|WKR7C2BZHq zr-K$k-0%|C#+Q{Mhc3FT2~wu^8%cHB0c--YhkuD^DZ-alP3Bpr$ngQk&d6!`e-~3rJbKnobGna)mAIR1E zex3!O1H}j;lGIM^C_&*4pO3#c*(FDkBt94NlL%~&|8ubE~e!_ zJ||qAFkNya9{GflZ*)R(OR${pU2VH7iK874|43QZ)?(GIp>{mj0 zI)LxEXJTvHKnEq$k!5_EAt~5t>Qr5|Ba7S3%VQU0^vQ+*-F3-6SmeXo>QG38M=k7` zk9wfEMe8r3YKaiarcWu67$3++lqg1a%n~uFk2tWMAS&5LT-HviZSn};tImLS+H9?f zTkm0Tr1I{#*uIaTuQs)LXzD5=Zf=_b(z#(ZJrJ6D%n`yeYjY-krvs`rAN3L<0OYB) z(&UOwvs%%C%mCqm?_tig#6!zZT$m^EAz7ULHBBV61}aVvWsV9$#PxsdR0-Xcm*+>l z2l)R})h4EWeD=a=69(LWpXG>#RXu7I(>^#+?V^srW8+>Ry!S-yK6MwurEupquA#P`igw$Gcx{efedVs2`vo7^3@#9ijc`p5A)Zj|%vgBViWs z0D_$BxC5>@*<*@$v`tYg=EXV4+cS|fwdzsWBA8Q&Y%}aFj$7m(_0>f7iO0ab3-Mdg zY~H=4=9y$8JG>skwUyOq_9oTr^MJdJJM23rSl3GKSlO#@xzh>*nD(zpU3$v zLmN{f=hshw3!uC$(_hjuz}hvRG&HsP!LN5=FgNe1btJybB(=t=;S;%>xNa8=q2&eg zsG&Lr(X0B^3a>wATJBpOCLoo>`a<~`%Lpp3Dj1+qSy-}A5l2O`u>h!XQcYw~Ib5dw zNg>mvFN@ikSX7t+U2YWdGsy)qLy(m;LscSk7s?hcIVuijX_-a1g%HXTZxTZtms%he zy`$d|C9iDuNpT@30a)12=hF)w59FCdKSh$0AVHI=zzxz6;cdp`GB$L!*z5e`Lx6;a znha48^kEfULv@Qpa6Q~onG^E$>+4x#(x-^J0c!VEnH&Ge-bi&-Rs*{_NW(pFA1kIe z!)m^sBPoK~p(K?O+{h$^jS|8@7BT>Q?DNUli|3}wlNn;Kk^*>buRZ9Ix?kc*`T(F& zOU3|gR#?5E`rdjfJBT7jXU?&4#<*d%2Y#cnZ)OTf_{*d+IfB46z`B^YX_hXvbEG!M zhin#+i|uGxgoNCLf?~G10<;Gupc9p{p=tBMw}vhGXhP<=C4UQ%hPA?hf1(Phk)&}UOLAEP)%`NCAaf&!P*q9K76n2POL_8lD@@1ZV zKS%ZUp=Ktd*3w(8CIkA?!*iHh6&!a36gVc+UJHs8pQ~62zvwu$JQ4FZFA3Y4rv@A~ z?*usQLF*3jbt0?OZ0HctTnarbGjNf5kg+$|48ckUgdqw=_cDYauciexLi048xd&;= z;GTAep7j35=R1FI5b-ZF)EQXk*dhRp+!8ZcEE}`F<<2!-BWoHzc4cghjCqkljD{6%!yYaoKq%Kpn49k=vC_@QU@;<3M8aT>Rt#9! z5RZ+S2rSt!ms`R{v9!l%z#=d*TOA~MXcHfrHkD3QGO<_yLLH%}ypIo~VcaD~ckHW+ z@&2Z~%&PuKi|T-fFIYjbZ;p~lrC1DKwc;myJ7-Lkn{a8qJ|K^?&Yq`_^D7u*)^@QS zqT&7T=LxKH4Kekge44yndh3?BYQp45VzjpAL>7#jKLIBmPSGLg03DHF0RqbWLEk7N z~wz1N>UGIUs%2q_C9N_cf2T zX?p);4uMf#ftrXn=JmHOaJQj~H$*Y%z`4apxMBo6tSq8YQq=#DKlo2marzrkK5s!8 z#P+$J@X={PeDevZ@JY}clSFrQ3*mzgvF{=y}Ma38TiJTpWW=G$8>4%o@e`bZzLA>X#u*Kq`%BYRrsvp1g2Dyu@MTmW{V`E|{ZB zc_f9Uj>u?=@f64*p6lvagC`#fFM+K+tFAC4{2-R+F4U7nK1$zxUuDu~jnz4pct2)w z@^A~DRkIgOq@25kD`VH)vF@VxtuL3koY0^_N5aGFRvb9l1&x~oGQ**8Y~ek~ zF2$WevM5X2Xy6?i#MG(v{o_wh=hM7$(u|W~ifhe$#(jC-;jxXW)>l8g-)MP&MDw~Suvw&ICZFSJL zJ%~*gikdi0I6Rn5T;iY>Me_BV+123w#m}nXdDp|6n7(| zB)2V)R{$7Cr46J`ZXz-bkfMIl#V}a1dnz|P{C4QHglg-cAQ2pvDr9~=L+V$qy{Z*I z6A-8Og=eI(E=~ z))a*O(|`B}kgXXA_io^Yd8DOt{n4BOC04wXWB&A?zlNH3mjT4YvC>b|zRNq&w?DBH zRIo$+SG;$KjsA~rUmy41|MBLPOK-NkBBc7 zXDNrM(UU^EvwDsz7YI>poM_=AtOkPK1;R*(j)U1@!(5PMS)_ABddhj8t0^aTE|Q`vI%vQQX9MZagKI$l}CoghDKfJ5xc@;O}5- z&#^7$dAZq}2=p>{`6>vs2V)>W{5l{pyoWq6dZchu5*{3RJ#n}MKO~SEl;H#g)?jib z!MZ*N)@4pEPJC@rVL8%;sn_Mw8Q*6*Jy256X$4C`|GE@Pc0uf=qMxnLC&Ihc7yOATGX*f7kiTOAX&J!FJ@gSqQ+&5FS zs2q_J1+7=ma5Dm+bJaUdp~LZ^CO?;?SuXypWgoeeBy`lMsk>aSvtnl6NHq2>YWR7* z3YgDXwLnWAjK7uRwgNbZ*tUf1Tx|sf{4pdEfeh|IlZYy5?cIBb1dOv9IyK}BAF+<$ z1Vsx7cDj01bA*Y6!LUQQ-q{oZI1L1yNU8AA3E>&JKjHKex5>*h7+b{zLBNSGGT|i? z$_gYIaG>#BQA>!cbl5Jg73tk`Z-T%Ca(f3oao$jCPD6GYK`OZTzfZ`Dn^fqB<@2a7NN!jE1r`iW0yW2ETv-3LmQUCr76Q=N&8R6=tC zsmi%UU(|tQ>5m-2dx{R~{+uz`z45!=DdQ>f`?C-xVPWizMX$@$5#A8ky=IRe9BPGD z-sJw=fdauiY+QF_A6^QNsq*L6@ID3#%2EQJP|k7$1S-?q25aQiUFg_-N^5^&Lvr)+WD zOs9XBNZO+dfhDl5mSFbQuPSU1V5y={Yp%x4ykl@r+Enti14^# zBPLYeAf3%(2rAb2nzM9rTLr8=e0+8=fMvE9$Tb36ivmTYpZDoN89-Hp;?;K$3hx98 z@9g&O?U2Iq#}km1YQlBinyDg|8I?iSm>;(u7P#N^HQYvPF0_CK{!SSwxb!!Od~Ip_ m=oEi_=y?~rWYVw~$9_Ot#GuFb+#M; literal 0 HcmV?d00001 diff --git a/app/modules/marketplace/docs/job-queue.md b/app/modules/marketplace/docs/job-queue.md new file mode 100644 index 00000000..1f53eb0d --- /dev/null +++ b/app/modules/marketplace/docs/job-queue.md @@ -0,0 +1,716 @@ +# Letzshop Jobs & Tables Improvements + +Implementation plan for improving the Letzshop management page jobs display and table harmonization. + +## Status: Completed + +### Completed +- [x] Phase 1: Job Details Modal (commit cef80af) +- [x] Phase 2: Add store column to jobs table +- [x] Phase 3: Platform settings system (rows per page) +- [x] Phase 4: Numbered pagination for jobs table +- [x] Phase 5: Admin customer management page + +--- + +## Overview + +This plan addresses 6 improvements: + +1. Job details modal with proper display +2. Tab visibility fix when filters cleared +3. Add store column to jobs table +4. Harmonize all tables with table macro +5. Platform-wide rows per page setting +6. Build admin customer page + +--- + +## 1. Job Details Modal + +### Current Issue +- "View Details" shows a browser alert instead of a proper modal +- No detailed breakdown of export results + +### Requirements +- Create a proper modal for job details +- For exports: show products exported per language file +- Show store name/code +- Show full timestamps and duration +- Show error details if any + +### Implementation + +#### 1.1 Create Job Details Modal Template + +**File:** `app/templates/admin/partials/letzshop-jobs-table.html` + +Add modal after the table: + +```html + +
+
+
+

Job Details

+ +
+ +
+ +
+
Job ID: #
+
Type:
+
Status:
+
Store:
+
+ + +
+

Started:

+

Completed:

+

Duration:

+
+ + + + + + +
+
+
+``` + +#### 1.2 Update JavaScript State + +**File:** `static/admin/js/marketplace-letzshop.js` + +Add state variables: +```javascript +showJobDetailsModal: false, +selectedJobDetails: null, +``` + +Update `viewJobDetails` method: +```javascript +viewJobDetails(job) { + this.selectedJobDetails = job; + this.showJobDetailsModal = true; +}, +``` + +#### 1.3 Update API to Return Full Details + +**File:** `app/services/letzshop/order_service.py` + +Update `list_letzshop_jobs` to include `error_details` in the response for export jobs. + +--- + +## 2. Tab Visibility Fix + +### Current Issue +- When store filter is cleared, only 2 tabs appear (Orders, Exceptions) +- Should show all tabs: Products, Orders, Exceptions, Jobs, Settings + +### Root Cause +- Products, Jobs, and Settings tabs are wrapped in `