docs: migrate module documentation to single source of truth
Move 39 documentation files from top-level docs/ into each module's docs/ folder, accessible via symlinks from docs/modules/. Create data-model.md files for 10 modules with full schema documentation. Replace originals with redirect stubs. Remove empty guide stubs. Modules migrated: tenancy, billing, loyalty, marketplace, orders, messaging, cms, catalog, inventory, hosting, prospecting. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,187 +1 @@
|
||||
# Admin Notification System
|
||||
|
||||
## Overview
|
||||
|
||||
The admin notification system provides real-time alerts and notifications to platform administrators for important events, errors, and system status updates.
|
||||
|
||||
## Components
|
||||
|
||||
### Backend
|
||||
|
||||
#### Database Models
|
||||
|
||||
Located in `models/database/admin.py`:
|
||||
|
||||
- **AdminNotification**: Stores individual notifications
|
||||
- `type`: Notification type (import_failure, order_sync_failure, etc.)
|
||||
- `priority`: low, normal, high, critical
|
||||
- `title`, `message`: Content
|
||||
- `is_read`, `read_at`, `read_by_user_id`: Read tracking
|
||||
- `action_required`, `action_url`: Optional action link
|
||||
- `notification_metadata`: JSON for additional context
|
||||
|
||||
- **PlatformAlert**: Stores platform-wide alerts
|
||||
- `alert_type`: security, performance, capacity, integration, etc.
|
||||
- `severity`: info, warning, error, critical
|
||||
- `affected_stores`, `affected_systems`: Scope tracking
|
||||
- `occurrence_count`, `first_occurred_at`, `last_occurred_at`: Deduplication
|
||||
- `is_resolved`, `resolved_at`, `resolution_notes`: Resolution tracking
|
||||
|
||||
#### Service Layer
|
||||
|
||||
Located in `app/services/admin_notification_service.py`:
|
||||
|
||||
```python
|
||||
from app.services.admin_notification_service import (
|
||||
admin_notification_service,
|
||||
platform_alert_service,
|
||||
NotificationType,
|
||||
Priority,
|
||||
AlertType,
|
||||
Severity,
|
||||
)
|
||||
```
|
||||
|
||||
**AdminNotificationService** methods:
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `create_notification()` | Create a new notification |
|
||||
| `get_notifications()` | List notifications with filters |
|
||||
| `get_recent_notifications()` | Get recent unread for header dropdown |
|
||||
| `get_unread_count()` | Count unread notifications |
|
||||
| `mark_as_read()` | Mark single notification read |
|
||||
| `mark_all_as_read()` | Mark all as read |
|
||||
| `delete_notification()` | Delete a notification |
|
||||
|
||||
**Convenience methods** for common scenarios:
|
||||
|
||||
| Method | Use Case |
|
||||
|--------|----------|
|
||||
| `notify_import_failure()` | Product/order import failed |
|
||||
| `notify_order_sync_failure()` | Letzshop sync failed |
|
||||
| `notify_order_exception()` | Order has unmatched products |
|
||||
| `notify_critical_error()` | System critical error |
|
||||
| `notify_store_issue()` | Store-related problem |
|
||||
| `notify_security_alert()` | Security event detected |
|
||||
|
||||
**PlatformAlertService** methods:
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `create_alert()` | Create a new platform alert |
|
||||
| `get_alerts()` | List alerts with filters |
|
||||
| `resolve_alert()` | Mark alert as resolved |
|
||||
| `get_statistics()` | Get alert counts and stats |
|
||||
| `create_or_increment_alert()` | Deduplicate recurring alerts |
|
||||
|
||||
#### API Endpoints
|
||||
|
||||
Located in `app/api/v1/admin/notifications.py`:
|
||||
|
||||
**Notifications:**
|
||||
- `GET /api/v1/admin/notifications` - List with filters
|
||||
- `POST /api/v1/admin/notifications` - Create (manual)
|
||||
- `GET /api/v1/admin/notifications/recent` - For header dropdown
|
||||
- `GET /api/v1/admin/notifications/unread-count` - Badge count
|
||||
- `PUT /api/v1/admin/notifications/{id}/read` - Mark read
|
||||
- `PUT /api/v1/admin/notifications/mark-all-read` - Mark all read
|
||||
- `DELETE /api/v1/admin/notifications/{id}` - Delete
|
||||
|
||||
**Platform Alerts:**
|
||||
- `GET /api/v1/admin/notifications/alerts` - List with filters
|
||||
- `POST /api/v1/admin/notifications/alerts` - Create (manual)
|
||||
- `PUT /api/v1/admin/notifications/alerts/{id}/resolve` - Resolve
|
||||
- `GET /api/v1/admin/notifications/alerts/stats` - Statistics
|
||||
|
||||
### Frontend
|
||||
|
||||
#### Header Dropdown
|
||||
|
||||
Located in `app/templates/admin/partials/header.html`:
|
||||
|
||||
- Real-time notification bell with unread count badge
|
||||
- Polls for new notifications every 60 seconds
|
||||
- Quick actions: mark as read, view all
|
||||
- Priority-based color coding
|
||||
|
||||
#### Notifications Page
|
||||
|
||||
Located in `app/templates/admin/notifications.html` with `static/admin/js/notifications.js`:
|
||||
|
||||
- Full notifications management interface
|
||||
- Two tabs: Notifications and Platform Alerts
|
||||
- Statistics cards (unread, active alerts, critical, resolved today)
|
||||
- Filtering by priority, type, read status
|
||||
- Bulk operations (mark all read)
|
||||
- Alert resolution workflow
|
||||
|
||||
## Automatic Triggers
|
||||
|
||||
Notifications are automatically created in these scenarios:
|
||||
|
||||
### Import Failures
|
||||
|
||||
**Product Import** (`app/tasks/background_tasks.py`):
|
||||
- When a product import job fails completely
|
||||
- When import completes with 5+ errors
|
||||
|
||||
**Historical Order Import** (`app/tasks/letzshop_tasks.py`):
|
||||
- When Letzshop API returns an error
|
||||
- When import fails with an unexpected exception
|
||||
|
||||
### Example Usage
|
||||
|
||||
```python
|
||||
from app.services.admin_notification_service import admin_notification_service
|
||||
|
||||
# In a background task or service
|
||||
admin_notification_service.notify_import_failure(
|
||||
db=db,
|
||||
store_name="Acme Corp",
|
||||
job_id=123,
|
||||
error_message="CSV parsing failed: invalid column format",
|
||||
store_id=5,
|
||||
)
|
||||
db.commit()
|
||||
```
|
||||
|
||||
## Priority Levels
|
||||
|
||||
| Priority | When to Use | Badge Color |
|
||||
|----------|-------------|-------------|
|
||||
| `critical` | System down, data loss risk | Red |
|
||||
| `high` | Import/sync failures, action needed | Orange |
|
||||
| `normal` | Informational alerts | Blue |
|
||||
| `low` | Minor issues, suggestions | Gray |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────────┐
|
||||
│ Background │────▶│ Notification │
|
||||
│ Tasks │ │ Service │
|
||||
└─────────────────┘ └──────────┬───────────┘
|
||||
│
|
||||
┌─────────────────┐ ▼
|
||||
│ API Endpoints │◀───────────────┤
|
||||
└─────────────────┘ │
|
||||
▼
|
||||
┌─────────────────┐ ┌──────────────────────┐
|
||||
│ Header │◀────│ Database │
|
||||
│ Dropdown │ │ (admin_notifications│
|
||||
└─────────────────┘ │ platform_alerts) │
|
||||
│ └──────────────────────┘
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ Notifications │
|
||||
│ Page │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Email notifications for critical alerts
|
||||
- Webhook integration for external systems
|
||||
- Customizable notification preferences per admin
|
||||
- Scheduled notification digests
|
||||
This document has moved to the messaging module docs: [Notifications](../modules/messaging/notifications.md)
|
||||
|
||||
@@ -1,308 +1 @@
|
||||
# Email Settings Implementation
|
||||
|
||||
This document describes the technical implementation of the email settings system for both store and platform (admin) configurations.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Email System Architecture │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ Platform Email │ │ Store Email │ │
|
||||
│ │ (Admin/Billing)│ │ (Customer-facing) │ │
|
||||
│ └────────┬─────────┘ └────────┬─────────┘ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ get_platform_ │ │ get_store_ │ │
|
||||
│ │ email_config(db) │ │ provider() │ │
|
||||
│ └────────┬─────────┘ └────────┬─────────┘ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ AdminSettings DB │ │StoreEmailSettings│ │
|
||||
│ │ (.env fallback)│ │ (per store) │ │
|
||||
│ └────────┬─────────┘ └────────┬─────────┘ │
|
||||
│ │ │ │
|
||||
│ └───────────┬───────────────┘ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────┐ │
|
||||
│ │ EmailService │ │
|
||||
│ │ send_raw() │ │
|
||||
│ └────────┬─────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────┐ │
|
||||
│ │ Email Providers │ │
|
||||
│ │ SMTP/SG/MG/SES │ │
|
||||
│ └──────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Database Models
|
||||
|
||||
### StoreEmailSettings
|
||||
|
||||
```python
|
||||
# models/database/store_email_settings.py
|
||||
|
||||
class StoreEmailSettings(Base):
|
||||
__tablename__ = "store_email_settings"
|
||||
|
||||
id: int
|
||||
store_id: int # FK to stores.id (one-to-one)
|
||||
|
||||
# Sender Identity
|
||||
from_email: str
|
||||
from_name: str
|
||||
reply_to_email: str | None
|
||||
|
||||
# Signature
|
||||
signature_text: str | None
|
||||
signature_html: str | None
|
||||
|
||||
# Provider
|
||||
provider: str = "smtp" # smtp, sendgrid, mailgun, ses
|
||||
|
||||
# SMTP Settings
|
||||
smtp_host: str | None
|
||||
smtp_port: int = 587
|
||||
smtp_username: str | None
|
||||
smtp_password: str | None
|
||||
smtp_use_tls: bool = True
|
||||
smtp_use_ssl: bool = False
|
||||
|
||||
# SendGrid
|
||||
sendgrid_api_key: str | None
|
||||
|
||||
# Mailgun
|
||||
mailgun_api_key: str | None
|
||||
mailgun_domain: str | None
|
||||
|
||||
# SES
|
||||
ses_access_key_id: str | None
|
||||
ses_secret_access_key: str | None
|
||||
ses_region: str = "eu-west-1"
|
||||
|
||||
# Status
|
||||
is_configured: bool = False
|
||||
is_verified: bool = False
|
||||
last_verified_at: datetime | None
|
||||
verification_error: str | None
|
||||
```
|
||||
|
||||
### Admin Settings (Platform Email)
|
||||
|
||||
Platform email settings are stored in the generic `admin_settings` table with category="email":
|
||||
|
||||
```python
|
||||
# Keys stored in admin_settings table
|
||||
EMAIL_SETTING_KEYS = {
|
||||
"email_provider",
|
||||
"email_from_address",
|
||||
"email_from_name",
|
||||
"email_reply_to",
|
||||
"smtp_host",
|
||||
"smtp_port",
|
||||
"smtp_user",
|
||||
"smtp_password",
|
||||
"smtp_use_tls",
|
||||
"smtp_use_ssl",
|
||||
"sendgrid_api_key",
|
||||
"mailgun_api_key",
|
||||
"mailgun_domain",
|
||||
"aws_access_key_id",
|
||||
"aws_secret_access_key",
|
||||
"aws_region",
|
||||
"email_enabled",
|
||||
"email_debug",
|
||||
}
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Store Email Settings
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/v1/store/email-settings` | GET | Get current email settings |
|
||||
| `/api/v1/store/email-settings` | PUT | Create/update email settings |
|
||||
| `/api/v1/store/email-settings` | DELETE | Delete email settings |
|
||||
| `/api/v1/store/email-settings/status` | GET | Get configuration status |
|
||||
| `/api/v1/store/email-settings/providers` | GET | Get available providers for tier |
|
||||
| `/api/v1/store/email-settings/verify` | POST | Send test email |
|
||||
|
||||
### Admin Email Settings
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/v1/admin/settings/email/status` | GET | Get effective email config |
|
||||
| `/api/v1/admin/settings/email/settings` | PUT | Update email settings in DB |
|
||||
| `/api/v1/admin/settings/email/settings` | DELETE | Reset to .env defaults |
|
||||
| `/api/v1/admin/settings/email/test` | POST | Send test email |
|
||||
|
||||
## Services
|
||||
|
||||
### StoreEmailSettingsService
|
||||
|
||||
Location: `app/services/store_email_settings_service.py`
|
||||
|
||||
Key methods:
|
||||
- `get_settings(store_id)` - Get settings for a store
|
||||
- `create_or_update(store_id, data, current_tier)` - Create/update settings
|
||||
- `delete(store_id)` - Delete settings
|
||||
- `verify_settings(store_id, test_email)` - Send test email
|
||||
- `get_available_providers(tier)` - Get providers for subscription tier
|
||||
|
||||
### EmailService Integration
|
||||
|
||||
The EmailService (`app/services/email_service.py`) uses:
|
||||
|
||||
1. **Platform Config**: `get_platform_email_config(db)` checks database first, then .env
|
||||
2. **Store Config**: `get_store_provider(settings)` creates provider from StoreEmailSettings
|
||||
3. **Provider Selection**: `send_raw()` uses store provider when `store_id` provided and `is_platform_email=False`
|
||||
|
||||
```python
|
||||
# EmailService.send_raw() flow
|
||||
def send_raw(self, to_email, subject, body_html, store_id=None, is_platform_email=False):
|
||||
if store_id and not is_platform_email:
|
||||
# Use store's email provider
|
||||
store_settings = self._get_store_email_settings(store_id)
|
||||
if store_settings and store_settings.is_configured:
|
||||
provider = get_store_provider(store_settings)
|
||||
else:
|
||||
# Use platform provider (DB config > .env)
|
||||
provider = self.provider # Set in __init__ via get_platform_provider(db)
|
||||
```
|
||||
|
||||
## Tier-Based Features
|
||||
|
||||
### Premium Provider Gating
|
||||
|
||||
Premium providers (SendGrid, Mailgun, SES) are gated to Business+ tiers:
|
||||
|
||||
```python
|
||||
PREMIUM_EMAIL_PROVIDERS = {EmailProvider.SENDGRID, EmailProvider.MAILGUN, EmailProvider.SES}
|
||||
PREMIUM_TIERS = {TierCode.BUSINESS, TierCode.ENTERPRISE}
|
||||
|
||||
def create_or_update(self, store_id, data, current_tier):
|
||||
provider = data.get("provider", "smtp")
|
||||
if provider in [p.value for p in PREMIUM_EMAIL_PROVIDERS]:
|
||||
if current_tier not in PREMIUM_TIERS:
|
||||
raise AuthorizationException(...)
|
||||
```
|
||||
|
||||
### White-Label Branding
|
||||
|
||||
Emails include "Powered by Orion" footer for non-whitelabel tiers:
|
||||
|
||||
```python
|
||||
WHITELABEL_TIERS = {"business", "enterprise"}
|
||||
|
||||
POWERED_BY_FOOTER_HTML = """
|
||||
<div style="margin-top: 30px; ...">
|
||||
<p>Powered by <a href="https://orion.lu">Orion</a></p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
def _inject_powered_by_footer(self, body_html, store_id):
|
||||
tier = self._get_store_tier(store_id)
|
||||
if tier and tier.lower() in WHITELABEL_TIERS:
|
||||
return body_html # No footer for business/enterprise
|
||||
return body_html.replace("</body>", f"{POWERED_BY_FOOTER_HTML}</body>")
|
||||
```
|
||||
|
||||
## Configuration Priority
|
||||
|
||||
### Platform Email
|
||||
|
||||
1. **Database** (admin_settings table) - Highest priority
|
||||
2. **Environment Variables** (.env) - Fallback
|
||||
|
||||
```python
|
||||
def get_platform_email_config(db: Session) -> dict:
|
||||
def get_db_setting(key: str) -> str | None:
|
||||
setting = db.query(AdminSetting).filter(AdminSetting.key == key).first()
|
||||
return setting.value if setting else None
|
||||
|
||||
# Check DB first, fallback to .env
|
||||
db_provider = get_db_setting("email_provider")
|
||||
config["provider"] = db_provider if db_provider else settings.email_provider
|
||||
...
|
||||
```
|
||||
|
||||
### Store Email
|
||||
|
||||
Stores have their own dedicated settings table with no fallback - they must configure their own email.
|
||||
|
||||
## Frontend Components
|
||||
|
||||
### Store Settings Page
|
||||
|
||||
- **Location**: `app/templates/store/settings.html`, `static/store/js/settings.js`
|
||||
- **Alpine.js State**: `emailSettings`, `emailForm`, `hasEmailChanges`
|
||||
- **Methods**: `loadEmailSettings()`, `saveEmailSettings()`, `sendTestEmail()`
|
||||
|
||||
### Admin Settings Page
|
||||
|
||||
- **Location**: `app/templates/admin/settings.html`, `static/admin/js/settings.js`
|
||||
- **Alpine.js State**: `emailSettings`, `emailForm`, `emailEditMode`
|
||||
- **Methods**: `loadEmailSettings()`, `saveEmailSettings()`, `resetEmailSettings()`, `sendTestEmail()`
|
||||
|
||||
### Warning Banner
|
||||
|
||||
Shows until email is configured:
|
||||
|
||||
```html
|
||||
<!-- app/templates/shared/macros/feature_gate.html -->
|
||||
{% macro email_settings_warning() %}
|
||||
<div x-data="emailSettingsWarning()" x-show="showWarning">
|
||||
Configure email settings to send emails to customers.
|
||||
</div>
|
||||
{% endmacro %}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
|
||||
Location: `tests/unit/services/test_store_email_settings_service.py`
|
||||
|
||||
Tests:
|
||||
- Read operations (get_settings, get_status, is_configured)
|
||||
- Write operations (create_or_update, delete)
|
||||
- Tier validation (premium providers)
|
||||
- Verification (mock SMTP)
|
||||
- Provider availability
|
||||
|
||||
### Integration Tests
|
||||
|
||||
Locations:
|
||||
- `tests/integration/api/v1/store/test_email_settings.py`
|
||||
- `tests/integration/api/v1/admin/test_email_settings.py`
|
||||
|
||||
Tests:
|
||||
- CRUD operations via API
|
||||
- Authentication/authorization
|
||||
- Validation errors
|
||||
- Status endpoints
|
||||
|
||||
## Files Modified/Created
|
||||
|
||||
### New Files
|
||||
- `models/database/store_email_settings.py` - Model
|
||||
- `alembic/versions/v0a1b2c3d4e5_add_store_email_settings.py` - Migration
|
||||
- `app/services/store_email_settings_service.py` - Service
|
||||
- `app/api/v1/store/email_settings.py` - API endpoints
|
||||
- `scripts/seed/install.py` - Installation wizard
|
||||
|
||||
### Modified Files
|
||||
- `app/services/email_service.py` - Added platform config, store providers
|
||||
- `app/api/v1/admin/settings.py` - Added email endpoints
|
||||
- `app/templates/admin/settings.html` - Email tab
|
||||
- `app/templates/store/settings.html` - Email tab
|
||||
- `static/admin/js/settings.js` - Email JS
|
||||
- `static/store/js/settings.js` - Email JS
|
||||
- `static/store/js/init-alpine.js` - Warning banner component
|
||||
This document has moved to the messaging module docs: [Email Settings Implementation](../modules/messaging/email-settings-impl.md)
|
||||
|
||||
@@ -1,458 +1 @@
|
||||
# Email Template System
|
||||
|
||||
## Overview
|
||||
|
||||
The email template system provides comprehensive email customization for the Orion platform with the following features:
|
||||
|
||||
- **Platform-level templates** with store overrides
|
||||
- **Orion branding** by default (removed for Enterprise whitelabel tier)
|
||||
- **Platform-only templates** that cannot be overridden (billing, subscriptions)
|
||||
- **Admin UI** for editing platform templates
|
||||
- **Store UI** for customizing customer-facing emails
|
||||
- **4-language support** (en, fr, de, lb)
|
||||
- **Smart language resolution** (customer → store → platform default)
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Database Models
|
||||
|
||||
#### EmailTemplate (Platform Templates)
|
||||
**File:** `models/database/email.py`
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | Integer | Primary key |
|
||||
| `code` | String(100) | Unique template identifier |
|
||||
| `language` | String(5) | Language code (en, fr, de, lb) |
|
||||
| `name` | String(255) | Human-readable name |
|
||||
| `description` | Text | Template description |
|
||||
| `category` | Enum | AUTH, ORDERS, BILLING, SYSTEM, MARKETING |
|
||||
| `subject` | String(500) | Email subject line (Jinja2) |
|
||||
| `body_html` | Text | HTML body (Jinja2) |
|
||||
| `body_text` | Text | Plain text body (Jinja2) |
|
||||
| `variables` | JSON | List of available variables |
|
||||
| `is_platform_only` | Boolean | Cannot be overridden by stores |
|
||||
| `required_variables` | Text | Comma-separated required variables |
|
||||
|
||||
**Key Methods:**
|
||||
- `get_by_code_and_language(db, code, language)` - Get specific template
|
||||
- `get_overridable_templates(db)` - Get templates stores can customize
|
||||
|
||||
#### StoreEmailTemplate (Store Overrides)
|
||||
**File:** `models/database/store_email_template.py`
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `id` | Integer | Primary key |
|
||||
| `store_id` | Integer | FK to stores.id |
|
||||
| `template_code` | String(100) | References EmailTemplate.code |
|
||||
| `language` | String(5) | Language code |
|
||||
| `name` | String(255) | Custom name (optional) |
|
||||
| `subject` | String(500) | Custom subject |
|
||||
| `body_html` | Text | Custom HTML body |
|
||||
| `body_text` | Text | Custom plain text body |
|
||||
| `created_at` | DateTime | Creation timestamp |
|
||||
| `updated_at` | DateTime | Last update timestamp |
|
||||
|
||||
**Key Methods:**
|
||||
- `get_override(db, store_id, code, language)` - Get store override
|
||||
- `create_or_update(db, store_id, code, language, ...)` - Upsert override
|
||||
- `delete_override(db, store_id, code, language)` - Revert to platform default
|
||||
- `get_all_overrides_for_store(db, store_id)` - List all store overrides
|
||||
|
||||
### Unique Constraint
|
||||
```sql
|
||||
UNIQUE (store_id, template_code, language)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Email Template Service
|
||||
|
||||
**File:** `app/services/email_template_service.py`
|
||||
|
||||
The `EmailTemplateService` encapsulates all email template business logic, keeping API endpoints clean and focused on request/response handling.
|
||||
|
||||
### Admin Methods
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `list_platform_templates()` | List all platform templates grouped by code |
|
||||
| `get_template_categories()` | Get list of template categories |
|
||||
| `get_platform_template(code)` | Get template with all language versions |
|
||||
| `update_platform_template(code, language, data)` | Update platform template content |
|
||||
| `preview_template(code, language, variables)` | Generate preview with sample data |
|
||||
| `get_template_logs(code, limit)` | Get email logs for template |
|
||||
|
||||
### Store Methods
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `list_overridable_templates(store_id)` | List templates store can customize |
|
||||
| `get_store_template(store_id, code, language)` | Get template (override or platform default) |
|
||||
| `create_or_update_store_override(store_id, code, language, data)` | Save store customization |
|
||||
| `delete_store_override(store_id, code, language)` | Revert to platform default |
|
||||
| `preview_store_template(store_id, code, language, variables)` | Preview with store branding |
|
||||
|
||||
### Usage Example
|
||||
|
||||
```python
|
||||
from app.services.email_template_service import EmailTemplateService
|
||||
|
||||
service = EmailTemplateService(db)
|
||||
|
||||
# List templates for admin
|
||||
templates = service.list_platform_templates()
|
||||
|
||||
# Get store's view of a template
|
||||
template_data = service.get_store_template(store_id, "order_confirmation", "fr")
|
||||
|
||||
# Create store override
|
||||
service.create_or_update_store_override(
|
||||
store_id=store.id,
|
||||
code="order_confirmation",
|
||||
language="fr",
|
||||
subject="Votre commande {{ order_number }}",
|
||||
body_html="<html>...</html>",
|
||||
body_text="Plain text...",
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Email Service
|
||||
|
||||
**File:** `app/services/email_service.py`
|
||||
|
||||
### Language Resolution
|
||||
|
||||
Priority order for determining email language:
|
||||
|
||||
1. **Customer preferred language** (if customer exists)
|
||||
2. **Store storefront language** (store.storefront_language)
|
||||
3. **Platform default** (`en`)
|
||||
|
||||
```python
|
||||
def resolve_language(
|
||||
self,
|
||||
customer_id: int | None,
|
||||
store_id: int | None,
|
||||
explicit_language: str | None = None
|
||||
) -> str
|
||||
```
|
||||
|
||||
### Template Resolution
|
||||
|
||||
```python
|
||||
def resolve_template(
|
||||
self,
|
||||
template_code: str,
|
||||
language: str,
|
||||
store_id: int | None = None
|
||||
) -> ResolvedTemplate
|
||||
```
|
||||
|
||||
Resolution order:
|
||||
1. If `store_id` provided and template **not** platform-only:
|
||||
- Look for `StoreEmailTemplate` override
|
||||
- Fall back to platform `EmailTemplate`
|
||||
2. If no store or platform-only:
|
||||
- Use platform `EmailTemplate`
|
||||
3. Language fallback: `requested_language` → `en`
|
||||
|
||||
### Branding Resolution
|
||||
|
||||
```python
|
||||
def get_branding(self, store_id: int | None) -> BrandingContext
|
||||
```
|
||||
|
||||
| Scenario | Platform Name | Platform Logo |
|
||||
|----------|--------------|---------------|
|
||||
| No store | Orion | Orion logo |
|
||||
| Standard store | Orion | Orion logo |
|
||||
| Whitelabel store | Store name | Store logo |
|
||||
|
||||
Whitelabel is determined by the `white_label` feature flag on the store.
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Admin API
|
||||
|
||||
**File:** `app/api/v1/admin/email_templates.py`
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/v1/admin/email-templates` | List all platform templates |
|
||||
| GET | `/api/v1/admin/email-templates/categories` | Get template categories |
|
||||
| GET | `/api/v1/admin/email-templates/{code}` | Get template (all languages) |
|
||||
| GET | `/api/v1/admin/email-templates/{code}/{language}` | Get specific language version |
|
||||
| PUT | `/api/v1/admin/email-templates/{code}/{language}` | Update template |
|
||||
| POST | `/api/v1/admin/email-templates/{code}/preview` | Preview with sample data |
|
||||
| POST | `/api/v1/admin/email-templates/{code}/test` | Send test email |
|
||||
| GET | `/api/v1/admin/email-templates/{code}/logs` | View email logs for template |
|
||||
|
||||
### Store API
|
||||
|
||||
**File:** `app/api/v1/store/email_templates.py`
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/v1/store/email-templates` | List overridable templates |
|
||||
| GET | `/api/v1/store/email-templates/{code}` | Get template with override status |
|
||||
| GET | `/api/v1/store/email-templates/{code}/{language}` | Get specific language (override or default) |
|
||||
| PUT | `/api/v1/store/email-templates/{code}/{language}` | Create/update override |
|
||||
| DELETE | `/api/v1/store/email-templates/{code}/{language}` | Reset to platform default |
|
||||
| POST | `/api/v1/store/email-templates/{code}/preview` | Preview with store branding |
|
||||
| POST | `/api/v1/store/email-templates/{code}/test` | Send test email |
|
||||
|
||||
---
|
||||
|
||||
## User Interface
|
||||
|
||||
### Admin UI
|
||||
|
||||
**Page:** `/admin/email-templates`
|
||||
**Template:** `app/templates/admin/email-templates.html`
|
||||
**JavaScript:** `static/admin/js/email-templates.js`
|
||||
|
||||
Features:
|
||||
- Template list with category filtering
|
||||
- Edit modal with language tabs (en, fr, de, lb)
|
||||
- Platform-only indicator badge
|
||||
- Variable reference panel
|
||||
- HTML preview in iframe
|
||||
- Send test email functionality
|
||||
|
||||
### Store UI
|
||||
|
||||
**Page:** `/store/{store_code}/email-templates`
|
||||
**Template:** `app/templates/store/email-templates.html`
|
||||
**JavaScript:** `static/store/js/email-templates.js`
|
||||
|
||||
Features:
|
||||
- List of overridable templates with customization status
|
||||
- Language override badges (green = customized)
|
||||
- Edit modal with:
|
||||
- Language tabs
|
||||
- Source indicator (store override vs platform default)
|
||||
- Platform template reference
|
||||
- Revert to default button
|
||||
- Preview and test email functionality
|
||||
|
||||
---
|
||||
|
||||
## Template Categories
|
||||
|
||||
| Category | Description | Platform-Only |
|
||||
|----------|-------------|---------------|
|
||||
| AUTH | Authentication emails (welcome, password reset) | No |
|
||||
| ORDERS | Order-related emails (confirmation, shipped) | No |
|
||||
| BILLING | Subscription/payment emails | Yes |
|
||||
| SYSTEM | System emails (team invites, alerts) | No |
|
||||
| MARKETING | Marketing/promotional emails | No |
|
||||
|
||||
---
|
||||
|
||||
## Available Templates
|
||||
|
||||
### Customer-Facing (Overridable)
|
||||
|
||||
| Code | Category | Languages | Description |
|
||||
|------|----------|-----------|-------------|
|
||||
| `signup_welcome` | AUTH | en, fr, de, lb | Welcome email after store signup |
|
||||
| `order_confirmation` | ORDERS | en, fr, de, lb | Order confirmation to customer |
|
||||
| `password_reset` | AUTH | en, fr, de, lb | Password reset link |
|
||||
| `team_invite` | SYSTEM | en | Team member invitation |
|
||||
|
||||
### Platform-Only (Not Overridable)
|
||||
|
||||
| Code | Category | Languages | Description |
|
||||
|------|----------|-----------|-------------|
|
||||
| `subscription_welcome` | BILLING | en | Subscription confirmation |
|
||||
| `payment_failed` | BILLING | en | Failed payment notification |
|
||||
| `subscription_cancelled` | BILLING | en | Cancellation confirmation |
|
||||
| `trial_ending` | BILLING | en | Trial ending reminder |
|
||||
|
||||
---
|
||||
|
||||
## Template Variables
|
||||
|
||||
### Common Variables (Injected Automatically)
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `platform_name` | "Orion" or store name (whitelabel) |
|
||||
| `platform_logo_url` | Platform logo URL |
|
||||
| `support_email` | Support email address |
|
||||
| `store_name` | Store business name |
|
||||
| `store_logo_url` | Store logo URL |
|
||||
|
||||
### Template-Specific Variables
|
||||
|
||||
#### signup_welcome
|
||||
- `first_name`, `merchant_name`, `email`, `store_code`
|
||||
- `login_url`, `trial_days`, `tier_name`
|
||||
|
||||
#### order_confirmation
|
||||
- `customer_name`, `order_number`, `order_total`
|
||||
- `order_items_count`, `order_date`, `shipping_address`
|
||||
|
||||
#### password_reset
|
||||
- `customer_name`, `reset_link`, `expiry_hours`
|
||||
|
||||
#### team_invite
|
||||
- `invitee_name`, `inviter_name`, `store_name`
|
||||
- `role`, `accept_url`, `expires_in_days`
|
||||
|
||||
---
|
||||
|
||||
## Migration
|
||||
|
||||
**File:** `alembic/versions/u9c0d1e2f3g4_add_store_email_templates.py`
|
||||
|
||||
Run migration:
|
||||
```bash
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
The migration:
|
||||
1. Adds `is_platform_only` and `required_variables` columns to `email_templates`
|
||||
2. Creates `store_email_templates` table
|
||||
3. Adds unique constraint on `(store_id, template_code, language)`
|
||||
4. Creates indexes for performance
|
||||
|
||||
---
|
||||
|
||||
## Seeding Templates
|
||||
|
||||
**File:** `scripts/seed/seed_email_templates.py`
|
||||
|
||||
Run seed script:
|
||||
```bash
|
||||
python scripts/seed/seed_email_templates.py
|
||||
```
|
||||
|
||||
The script:
|
||||
- Creates/updates all platform templates
|
||||
- Supports all 4 languages for customer-facing templates
|
||||
- Sets `is_platform_only` flag for billing templates
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **XSS Prevention**: HTML templates are rendered server-side with Jinja2 escaping
|
||||
2. **Access Control**: Stores can only view/edit their own overrides
|
||||
3. **Platform-only Protection**: API enforces `is_platform_only` flag
|
||||
4. **Template Validation**: Jinja2 syntax validated before save
|
||||
5. **Rate Limiting**: Test email sending subject to rate limits
|
||||
6. **Token Hashing**: Password reset tokens stored as SHA256 hashes
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Sending a Template Email
|
||||
|
||||
```python
|
||||
from app.services.email_service import EmailService
|
||||
|
||||
email_svc = EmailService(db)
|
||||
email_log = email_svc.send_template(
|
||||
template_code="order_confirmation",
|
||||
to_email="customer@example.com",
|
||||
variables={
|
||||
"customer_name": "John Doe",
|
||||
"order_number": "ORD-12345",
|
||||
"order_total": "€99.99",
|
||||
"order_items_count": "3",
|
||||
"order_date": "2024-01-15",
|
||||
"shipping_address": "123 Main St, Luxembourg"
|
||||
},
|
||||
store_id=store.id, # Optional: enables store override lookup
|
||||
customer_id=customer.id, # Optional: for language resolution
|
||||
language="fr" # Optional: explicit language override
|
||||
)
|
||||
```
|
||||
|
||||
### Creating a Store Override
|
||||
|
||||
```python
|
||||
from models.database.store_email_template import StoreEmailTemplate
|
||||
|
||||
override = StoreEmailTemplate.create_or_update(
|
||||
db=db,
|
||||
store_id=store.id,
|
||||
template_code="order_confirmation",
|
||||
language="fr",
|
||||
subject="Confirmation de votre commande {{ order_number }}",
|
||||
body_html="<html>...</html>",
|
||||
body_text="Plain text version..."
|
||||
)
|
||||
db.commit()
|
||||
```
|
||||
|
||||
### Reverting to Platform Default
|
||||
|
||||
```python
|
||||
StoreEmailTemplate.delete_override(
|
||||
db=db,
|
||||
store_id=store.id,
|
||||
template_code="order_confirmation",
|
||||
language="fr"
|
||||
)
|
||||
db.commit()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
├── alembic/versions/
|
||||
│ └── u9c0d1e2f3g4_add_store_email_templates.py
|
||||
├── app/
|
||||
│ ├── api/v1/
|
||||
│ │ ├── admin/
|
||||
│ │ │ └── email_templates.py
|
||||
│ │ └── store/
|
||||
│ │ └── email_templates.py
|
||||
│ ├── routes/
|
||||
│ │ ├── admin_pages.py (route added)
|
||||
│ │ └── store_pages.py (route added)
|
||||
│ ├── services/
|
||||
│ │ ├── email_service.py (enhanced)
|
||||
│ │ └── email_template_service.py (new - business logic)
|
||||
│ └── templates/
|
||||
│ ├── admin/
|
||||
│ │ ├── email-templates.html
|
||||
│ │ └── partials/sidebar.html (link added)
|
||||
│ └── store/
|
||||
│ ├── email-templates.html
|
||||
│ └── partials/sidebar.html (link added)
|
||||
├── models/
|
||||
│ ├── database/
|
||||
│ │ ├── email.py (enhanced)
|
||||
│ │ └── store_email_template.py
|
||||
│ └── schema/
|
||||
│ └── email.py
|
||||
├── scripts/
|
||||
│ └── seed_email_templates.py (enhanced)
|
||||
└── static/
|
||||
├── admin/js/
|
||||
│ └── email-templates.js
|
||||
└── store/js/
|
||||
└── email-templates.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Email Templates User Guide](../guides/email-templates.md) - How to use the email template system
|
||||
- [Password Reset Implementation](./password-reset-implementation.md) - Password reset feature using email templates
|
||||
- [Architecture Fixes (January 2026)](../development/architecture-fixes-2026-01.md) - Architecture validation fixes
|
||||
This document has moved to the CMS module docs: [Email Templates](../modules/cms/email-templates.md)
|
||||
|
||||
@@ -1,434 +1,3 @@
|
||||
# Feature Gating System
|
||||
|
||||
## Overview
|
||||
|
||||
The feature gating system provides tier-based access control for platform features. It allows restricting functionality based on store subscription tiers (Essential, Professional, Business, Enterprise) with contextual upgrade prompts when features are locked.
|
||||
|
||||
**Implemented:** December 31, 2025
|
||||
|
||||
## Architecture
|
||||
|
||||
### Database Models
|
||||
|
||||
Located in `models/database/feature.py`:
|
||||
|
||||
| Model | Purpose |
|
||||
|-------|---------|
|
||||
| `Feature` | Feature definitions with tier requirements |
|
||||
| `StoreFeatureOverride` | Per-store feature overrides (enable/disable) |
|
||||
|
||||
### Feature Model Structure
|
||||
|
||||
```python
|
||||
class Feature(Base):
|
||||
__tablename__ = "features"
|
||||
|
||||
id: int # Primary key
|
||||
code: str # Unique feature code (e.g., "analytics_dashboard")
|
||||
name: str # Display name
|
||||
description: str # User-facing description
|
||||
category: str # Feature category
|
||||
minimum_tier_code: str # Minimum tier required (essential/professional/business/enterprise)
|
||||
minimum_tier_order: int # Tier order for comparison (1-4)
|
||||
is_active: bool # Whether feature is available
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
```
|
||||
|
||||
### Tier Ordering
|
||||
|
||||
| Tier | Order | Code |
|
||||
|------|-------|------|
|
||||
| Essential | 1 | `essential` |
|
||||
| Professional | 2 | `professional` |
|
||||
| Business | 3 | `business` |
|
||||
| Enterprise | 4 | `enterprise` |
|
||||
|
||||
## Feature Categories
|
||||
|
||||
30 features organized into 8 categories:
|
||||
|
||||
### 1. Analytics
|
||||
| Feature Code | Name | Min Tier |
|
||||
|-------------|------|----------|
|
||||
| `basic_analytics` | Basic Analytics | Essential |
|
||||
| `analytics_dashboard` | Analytics Dashboard | Professional |
|
||||
| `advanced_analytics` | Advanced Analytics | Business |
|
||||
| `custom_reports` | Custom Reports | Enterprise |
|
||||
|
||||
### 2. Product Management
|
||||
| Feature Code | Name | Min Tier |
|
||||
|-------------|------|----------|
|
||||
| `basic_products` | Product Management | Essential |
|
||||
| `bulk_product_edit` | Bulk Product Edit | Professional |
|
||||
| `product_variants` | Product Variants | Professional |
|
||||
| `product_bundles` | Product Bundles | Business |
|
||||
| `inventory_alerts` | Inventory Alerts | Professional |
|
||||
|
||||
### 3. Order Management
|
||||
| Feature Code | Name | Min Tier |
|
||||
|-------------|------|----------|
|
||||
| `basic_orders` | Order Management | Essential |
|
||||
| `order_automation` | Order Automation | Professional |
|
||||
| `advanced_fulfillment` | Advanced Fulfillment | Business |
|
||||
| `multi_warehouse` | Multi-Warehouse | Enterprise |
|
||||
|
||||
### 4. Marketing
|
||||
| Feature Code | Name | Min Tier |
|
||||
|-------------|------|----------|
|
||||
| `discount_codes` | Discount Codes | Professional |
|
||||
| `abandoned_cart` | Abandoned Cart Recovery | Business |
|
||||
| `email_marketing` | Email Marketing | Business |
|
||||
| `loyalty_program` | Loyalty Program | Enterprise |
|
||||
|
||||
### 5. Support
|
||||
| Feature Code | Name | Min Tier |
|
||||
|-------------|------|----------|
|
||||
| `basic_support` | Email Support | Essential |
|
||||
| `priority_support` | Priority Support | Professional |
|
||||
| `phone_support` | Phone Support | Business |
|
||||
| `dedicated_manager` | Dedicated Account Manager | Enterprise |
|
||||
|
||||
### 6. Integration
|
||||
| Feature Code | Name | Min Tier |
|
||||
|-------------|------|----------|
|
||||
| `basic_api` | Basic API Access | Professional |
|
||||
| `advanced_api` | Advanced API Access | Business |
|
||||
| `webhooks` | Webhooks | Business |
|
||||
| `custom_integrations` | Custom Integrations | Enterprise |
|
||||
|
||||
### 7. Branding
|
||||
| Feature Code | Name | Min Tier |
|
||||
|-------------|------|----------|
|
||||
| `basic_theme` | Theme Customization | Essential |
|
||||
| `custom_domain` | Custom Domain | Professional |
|
||||
| `white_label` | White Label | Enterprise |
|
||||
| `custom_checkout` | Custom Checkout | Enterprise |
|
||||
|
||||
### 8. Team
|
||||
| Feature Code | Name | Min Tier |
|
||||
|-------------|------|----------|
|
||||
| `team_management` | Team Management | Professional |
|
||||
| `role_permissions` | Role Permissions | Business |
|
||||
| `audit_logs` | Audit Logs | Business |
|
||||
|
||||
## Services
|
||||
|
||||
### FeatureService
|
||||
|
||||
Located in `app/services/feature_service.py`:
|
||||
|
||||
```python
|
||||
class FeatureService:
|
||||
"""Service for managing tier-based feature access."""
|
||||
|
||||
# In-memory caching (refreshed every 5 minutes)
|
||||
_feature_cache: dict[str, Feature] = {}
|
||||
_cache_timestamp: datetime | None = None
|
||||
CACHE_TTL_SECONDS = 300
|
||||
|
||||
def has_feature(self, db: Session, store_id: int, feature_code: str) -> bool:
|
||||
"""Check if store has access to a feature."""
|
||||
|
||||
def get_available_features(self, db: Session, store_id: int) -> list[str]:
|
||||
"""Get list of feature codes available to store."""
|
||||
|
||||
def get_all_features_with_status(self, db: Session, store_id: int) -> list[dict]:
|
||||
"""Get all features with availability status for store."""
|
||||
|
||||
def get_feature_info(self, db: Session, feature_code: str) -> dict | None:
|
||||
"""Get full feature information including tier requirements."""
|
||||
```
|
||||
|
||||
### UsageService
|
||||
|
||||
Located in `app/services/usage_service.py`:
|
||||
|
||||
```python
|
||||
class UsageService:
|
||||
"""Service for tracking and managing store usage against tier limits."""
|
||||
|
||||
def get_usage_summary(self, db: Session, store_id: int) -> dict:
|
||||
"""Get comprehensive usage summary with limits and upgrade info."""
|
||||
|
||||
def check_limit(self, db: Session, store_id: int, limit_type: str) -> dict:
|
||||
"""Check specific limit with detailed info."""
|
||||
|
||||
def get_upgrade_info(self, db: Session, store_id: int) -> dict:
|
||||
"""Get upgrade recommendations based on current usage."""
|
||||
```
|
||||
|
||||
## Backend Enforcement
|
||||
|
||||
### Decorator Pattern
|
||||
|
||||
```python
|
||||
from app.core.feature_gate import require_feature
|
||||
|
||||
@router.get("/analytics/advanced")
|
||||
@require_feature("advanced_analytics")
|
||||
async def get_advanced_analytics(
|
||||
db: Session = Depends(get_db),
|
||||
store_id: int = Depends(get_current_store_id)
|
||||
):
|
||||
# Only accessible if store has advanced_analytics feature
|
||||
pass
|
||||
```
|
||||
|
||||
### Dependency Pattern
|
||||
|
||||
```python
|
||||
from app.core.feature_gate import RequireFeature
|
||||
|
||||
@router.get("/marketing/loyalty")
|
||||
async def get_loyalty_program(
|
||||
db: Session = Depends(get_db),
|
||||
_: None = Depends(RequireFeature("loyalty_program"))
|
||||
):
|
||||
# Only accessible if store has loyalty_program feature
|
||||
pass
|
||||
```
|
||||
|
||||
### Exception Handling
|
||||
|
||||
When a feature is not available, `FeatureNotAvailableException` is raised:
|
||||
|
||||
```python
|
||||
class FeatureNotAvailableException(Exception):
|
||||
def __init__(self, feature_code: str, required_tier: str):
|
||||
self.feature_code = feature_code
|
||||
self.required_tier = required_tier
|
||||
super().__init__(f"Feature '{feature_code}' requires {required_tier} tier")
|
||||
```
|
||||
|
||||
HTTP Response (403):
|
||||
```json
|
||||
{
|
||||
"detail": "Feature 'advanced_analytics' requires Professional tier or higher",
|
||||
"feature_code": "advanced_analytics",
|
||||
"required_tier": "Professional",
|
||||
"upgrade_url": "/store/orion/billing"
|
||||
}
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Store Features API
|
||||
|
||||
Base: `/api/v1/store/features`
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/features/available` | GET | List available feature codes |
|
||||
| `/features` | GET | All features with availability status |
|
||||
| `/features/{code}` | GET | Single feature info |
|
||||
| `/features/{code}/check` | GET | Quick availability check |
|
||||
|
||||
### Store Usage API
|
||||
|
||||
Base: `/api/v1/store/usage`
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/usage` | GET | Full usage summary with limits |
|
||||
| `/usage/check/{limit_type}` | GET | Check specific limit (orders/products/team_members) |
|
||||
| `/usage/upgrade-info` | GET | Upgrade recommendations |
|
||||
|
||||
### Admin Features API
|
||||
|
||||
Base: `/api/v1/admin/features`
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/features` | GET | List all features |
|
||||
| `/features/{id}` | GET | Get feature details |
|
||||
| `/features/{id}` | PUT | Update feature |
|
||||
| `/features/{id}/toggle` | POST | Toggle feature active status |
|
||||
| `/features/stores/{store_id}/overrides` | GET | Get store overrides |
|
||||
| `/features/stores/{store_id}/overrides` | POST | Create override |
|
||||
|
||||
## Frontend Integration
|
||||
|
||||
### Alpine.js Feature Store
|
||||
|
||||
Located in `static/shared/js/feature-store.js`:
|
||||
|
||||
```javascript
|
||||
// Usage in templates
|
||||
$store.features.has('analytics_dashboard') // Check feature
|
||||
$store.features.loaded // Loading state
|
||||
$store.features.getFeature('advanced_api') // Get feature details
|
||||
```
|
||||
|
||||
### Alpine.js Upgrade Store
|
||||
|
||||
Located in `static/shared/js/upgrade-prompts.js`:
|
||||
|
||||
```javascript
|
||||
// Usage in templates
|
||||
$store.upgrade.shouldShowLimitWarning('orders')
|
||||
$store.upgrade.getUsageString('products')
|
||||
$store.upgrade.hasUpgradeRecommendation
|
||||
```
|
||||
|
||||
### Jinja2 Macros
|
||||
|
||||
Located in `app/templates/shared/macros/feature_gate.html`:
|
||||
|
||||
#### Feature Gate Container
|
||||
```jinja2
|
||||
{% from "shared/macros/feature_gate.html" import feature_gate %}
|
||||
|
||||
{% call feature_gate("analytics_dashboard") %}
|
||||
<div>Analytics content here - only visible if feature available</div>
|
||||
{% endcall %}
|
||||
```
|
||||
|
||||
#### Feature Locked Card
|
||||
```jinja2
|
||||
{% from "shared/macros/feature_gate.html" import feature_locked %}
|
||||
|
||||
{{ feature_locked("advanced_analytics", "Advanced Analytics", "Get deeper insights") }}
|
||||
```
|
||||
|
||||
#### Upgrade Banner
|
||||
```jinja2
|
||||
{% from "shared/macros/feature_gate.html" import upgrade_banner %}
|
||||
|
||||
{{ upgrade_banner("custom_domain") }}
|
||||
```
|
||||
|
||||
#### Usage Limit Warning
|
||||
```jinja2
|
||||
{% from "shared/macros/feature_gate.html" import limit_warning %}
|
||||
|
||||
{{ limit_warning("orders") }} {# Shows warning when approaching limit #}
|
||||
```
|
||||
|
||||
#### Usage Progress Bar
|
||||
```jinja2
|
||||
{% from "shared/macros/feature_gate.html" import usage_bar %}
|
||||
|
||||
{{ usage_bar("products", "Products") }}
|
||||
```
|
||||
|
||||
#### Tier Badge
|
||||
```jinja2
|
||||
{% from "shared/macros/feature_gate.html" import tier_badge %}
|
||||
|
||||
{{ tier_badge() }} {# Shows current tier as colored badge #}
|
||||
```
|
||||
|
||||
## Store Dashboard Integration
|
||||
|
||||
The store dashboard (`/store/{code}/dashboard`) now includes:
|
||||
|
||||
1. **Tier Badge**: Shows current subscription tier in header
|
||||
2. **Usage Bars**: Visual progress bars for orders, products, team members
|
||||
3. **Upgrade Prompts**: Contextual upgrade recommendations when approaching limits
|
||||
4. **Feature Gates**: Locked sections for premium features
|
||||
|
||||
## Admin Features Page
|
||||
|
||||
Located at `/admin/features`:
|
||||
|
||||
- View all 30 features in categorized table
|
||||
- Toggle features on/off globally
|
||||
- Filter by category
|
||||
- Search by name/code
|
||||
- View tier requirements
|
||||
|
||||
## Admin Tier Management UI
|
||||
|
||||
Located at `/admin/subscription-tiers`:
|
||||
|
||||
### Overview
|
||||
|
||||
The subscription tiers admin page provides full CRUD functionality for managing subscription tiers and their feature assignments.
|
||||
|
||||
### Features
|
||||
|
||||
1. **Stats Cards**: Display total tiers, active tiers, public tiers, and estimated MRR
|
||||
2. **Tier Table**: Sortable list of all tiers with:
|
||||
- Display order
|
||||
- Code (colored badge by tier)
|
||||
- Name
|
||||
- Monthly/Annual pricing
|
||||
- Feature count
|
||||
- Status (Active/Private/Inactive)
|
||||
- Actions (Edit Features, Edit, Activate/Deactivate)
|
||||
|
||||
3. **Create/Edit Modal**: Form with all tier fields:
|
||||
- Code and Name
|
||||
- Monthly and Annual pricing (in cents)
|
||||
- Display order
|
||||
- Stripe IDs (optional)
|
||||
- Description
|
||||
- Active/Public toggles
|
||||
|
||||
4. **Feature Assignment Slide-over Panel**:
|
||||
- Opens when clicking the puzzle-piece icon
|
||||
- Shows all features grouped by category
|
||||
- Binary features: checkbox selection with Select all/Deselect all per category
|
||||
- Quantitative features: checkbox + numeric limit input for `limit_value`
|
||||
- Feature count in footer
|
||||
- Save to update tier's feature assignments via `TierFeatureLimitEntry[]`
|
||||
|
||||
### Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `app/templates/admin/subscription-tiers.html` | Page template |
|
||||
| `static/admin/js/subscription-tiers.js` | Alpine.js component |
|
||||
| `app/routes/admin_pages.py` | Route registration |
|
||||
|
||||
### API Endpoints Used
|
||||
|
||||
| Action | Method | Endpoint |
|
||||
|--------|--------|----------|
|
||||
| Load tiers | GET | `/api/v1/admin/subscriptions/tiers` |
|
||||
| Load stats | GET | `/api/v1/admin/subscriptions/stats` |
|
||||
| Create tier | POST | `/api/v1/admin/subscriptions/tiers` |
|
||||
| Update tier | PATCH | `/api/v1/admin/subscriptions/tiers/{code}` |
|
||||
| Delete tier | DELETE | `/api/v1/admin/subscriptions/tiers/{code}` |
|
||||
| Load feature catalog | GET | `/api/v1/admin/subscriptions/features/catalog` |
|
||||
| Get tier feature limits | GET | `/api/v1/admin/subscriptions/features/tiers/{code}/limits` |
|
||||
| Update tier feature limits | PUT | `/api/v1/admin/subscriptions/features/tiers/{code}/limits` |
|
||||
|
||||
## Migration
|
||||
|
||||
The features are seeded via Alembic migration:
|
||||
|
||||
```
|
||||
alembic/versions/n2c3d4e5f6a7_add_features_table.py
|
||||
```
|
||||
|
||||
This creates:
|
||||
- `features` table with 30 default features
|
||||
- `store_feature_overrides` table for per-store exceptions
|
||||
|
||||
## Testing
|
||||
|
||||
Unit tests located in:
|
||||
- `tests/unit/services/test_feature_service.py`
|
||||
- `tests/unit/services/test_usage_service.py`
|
||||
|
||||
Run tests:
|
||||
```bash
|
||||
pytest tests/unit/services/test_feature_service.py -v
|
||||
pytest tests/unit/services/test_usage_service.py -v
|
||||
```
|
||||
|
||||
## Architecture Compliance
|
||||
|
||||
All JavaScript files follow architecture rules:
|
||||
- JS-003: Alpine components use `store*` naming convention
|
||||
- JS-005: Init guards prevent duplicate initialization
|
||||
- JS-006: Async operations have try/catch error handling
|
||||
- JS-008: API calls use `apiClient` (not raw `fetch()`)
|
||||
- JS-009: Notifications use `Utils.showToast()`
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Subscription Billing](../features/subscription-billing.md) - Core subscription system
|
||||
- [Subscription Workflow Plan](./subscription-workflow-plan.md) - Implementation roadmap
|
||||
This document has moved to the billing module docs: [Feature Gating](../modules/billing/feature-gating.md)
|
||||
|
||||
@@ -1,716 +1,3 @@
|
||||
# Letzshop Jobs & Tables Improvements
|
||||
|
||||
Implementation plan for improving the Letzshop management page jobs display and table harmonization.
|
||||
|
||||
## Status: Completed
|
||||
|
||||
### Completed
|
||||
- [x] Phase 1: Job Details Modal (commit cef80af)
|
||||
- [x] Phase 2: Add store column to jobs table
|
||||
- [x] Phase 3: Platform settings system (rows per page)
|
||||
- [x] Phase 4: Numbered pagination for jobs table
|
||||
- [x] Phase 5: Admin customer management page
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This plan addresses 6 improvements:
|
||||
|
||||
1. Job details modal with proper display
|
||||
2. Tab visibility fix when filters cleared
|
||||
3. Add store column to jobs table
|
||||
4. Harmonize all tables with table macro
|
||||
5. Platform-wide rows per page setting
|
||||
6. Build admin customer page
|
||||
|
||||
---
|
||||
|
||||
## 1. Job Details Modal
|
||||
|
||||
### Current Issue
|
||||
- "View Details" shows a browser alert instead of a proper modal
|
||||
- No detailed breakdown of export results
|
||||
|
||||
### Requirements
|
||||
- Create a proper modal for job details
|
||||
- For exports: show products exported per language file
|
||||
- Show store name/code
|
||||
- Show full timestamps and duration
|
||||
- Show error details if any
|
||||
|
||||
### Implementation
|
||||
|
||||
#### 1.1 Create Job Details Modal Template
|
||||
|
||||
**File:** `app/templates/admin/partials/letzshop-jobs-table.html`
|
||||
|
||||
Add modal after the table:
|
||||
|
||||
```html
|
||||
<!-- Job Details Modal -->
|
||||
<div
|
||||
x-show="showJobDetailsModal"
|
||||
x-transition
|
||||
class="fixed inset-0 z-30 flex items-center justify-center bg-black bg-opacity-50"
|
||||
@click.self="showJobDetailsModal = false"
|
||||
x-cloak
|
||||
>
|
||||
<div class="w-full max-w-lg bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6">
|
||||
<header class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold">Job Details</h3>
|
||||
<button @click="showJobDetailsModal = false">×</button>
|
||||
</header>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Job Info -->
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div><span class="font-medium">Job ID:</span> #<span x-text="selectedJobDetails?.id"></span></div>
|
||||
<div><span class="font-medium">Type:</span> <span x-text="selectedJobDetails?.type"></span></div>
|
||||
<div><span class="font-medium">Status:</span> <span x-text="selectedJobDetails?.status"></span></div>
|
||||
<div><span class="font-medium">Store:</span> <span x-text="selectedJobDetails?.store_name || selectedStore?.name"></span></div>
|
||||
</div>
|
||||
|
||||
<!-- Timestamps -->
|
||||
<div class="text-sm">
|
||||
<p><span class="font-medium">Started:</span> <span x-text="formatDate(selectedJobDetails?.started_at)"></span></p>
|
||||
<p><span class="font-medium">Completed:</span> <span x-text="formatDate(selectedJobDetails?.completed_at)"></span></p>
|
||||
<p><span class="font-medium">Duration:</span> <span x-text="formatDuration(selectedJobDetails?.started_at, selectedJobDetails?.completed_at)"></span></p>
|
||||
</div>
|
||||
|
||||
<!-- Export Details (for export jobs) -->
|
||||
<template x-if="selectedJobDetails?.type === 'export' && selectedJobDetails?.error_details?.products_exported">
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-3">
|
||||
<h4 class="font-medium mb-2">Export Details</h4>
|
||||
<p class="text-sm">Products exported: <span x-text="selectedJobDetails.error_details.products_exported"></span></p>
|
||||
<template x-if="selectedJobDetails.error_details.files">
|
||||
<div class="mt-2 space-y-1">
|
||||
<template x-for="file in selectedJobDetails.error_details.files" :key="file.language">
|
||||
<div class="text-xs flex justify-between">
|
||||
<span x-text="file.language.toUpperCase()"></span>
|
||||
<span x-text="file.error ? 'Failed: ' + file.error : file.filename + ' (' + (file.size_bytes / 1024).toFixed(1) + ' KB)'"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error Details -->
|
||||
<template x-if="selectedJobDetails?.error_message || selectedJobDetails?.error_details?.error">
|
||||
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
|
||||
<h4 class="font-medium text-red-700 mb-2">Error</h4>
|
||||
<p class="text-sm text-red-600" x-text="selectedJobDetails?.error_message || selectedJobDetails?.error_details?.error"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### 1.2 Update JavaScript State
|
||||
|
||||
**File:** `static/admin/js/marketplace-letzshop.js`
|
||||
|
||||
Add state variables:
|
||||
```javascript
|
||||
showJobDetailsModal: false,
|
||||
selectedJobDetails: null,
|
||||
```
|
||||
|
||||
Update `viewJobDetails` method:
|
||||
```javascript
|
||||
viewJobDetails(job) {
|
||||
this.selectedJobDetails = job;
|
||||
this.showJobDetailsModal = true;
|
||||
},
|
||||
```
|
||||
|
||||
#### 1.3 Update API to Return Full Details
|
||||
|
||||
**File:** `app/services/letzshop/order_service.py`
|
||||
|
||||
Update `list_letzshop_jobs` to include `error_details` in the response for export jobs.
|
||||
|
||||
---
|
||||
|
||||
## 2. Tab Visibility Fix
|
||||
|
||||
### Current Issue
|
||||
- When store filter is cleared, only 2 tabs appear (Orders, Exceptions)
|
||||
- Should show all tabs: Products, Orders, Exceptions, Jobs, Settings
|
||||
|
||||
### Root Cause
|
||||
- Products, Jobs, and Settings tabs are wrapped in `<template x-if="selectedStore">`
|
||||
- This is intentional for store-specific features
|
||||
|
||||
### Decision Required
|
||||
**Option A:** Keep current behavior (store-specific tabs hidden when no store)
|
||||
- Products, Jobs, Settings require a store context
|
||||
- Cross-store view only shows Orders and Exceptions
|
||||
|
||||
**Option B:** Show all tabs but with "Select store" message
|
||||
- All tabs visible
|
||||
- Content shows prompt to select store
|
||||
|
||||
### Recommended: Option A (Current Behavior)
|
||||
The current behavior is correct because:
|
||||
- Products tab shows store's Letzshop products (needs store)
|
||||
- Jobs tab shows store's jobs (needs store)
|
||||
- Settings tab configures store's Letzshop (needs store)
|
||||
- Orders and Exceptions can work cross-store
|
||||
|
||||
**No change needed** - document this as intentional behavior.
|
||||
|
||||
---
|
||||
|
||||
## 3. Add Store Column to Jobs Table
|
||||
|
||||
### Requirements
|
||||
- Add store name/code column to jobs table
|
||||
- Useful when viewing cross-store (future feature)
|
||||
- Prepare for reusable jobs component
|
||||
|
||||
### Implementation
|
||||
|
||||
#### 3.1 Update API Response
|
||||
|
||||
**File:** `app/services/letzshop/order_service.py`
|
||||
|
||||
Add store info to job dicts:
|
||||
```python
|
||||
# In list_letzshop_jobs, add to each job dict:
|
||||
"store_id": store_id,
|
||||
"store_name": store.name if store else None,
|
||||
"store_code": store.store_code if store else None,
|
||||
```
|
||||
|
||||
Need to fetch store once at start of function.
|
||||
|
||||
#### 3.2 Update Table Template
|
||||
|
||||
**File:** `app/templates/admin/partials/letzshop-jobs-table.html`
|
||||
|
||||
Add column header:
|
||||
```html
|
||||
<th class="px-4 py-3">Store</th>
|
||||
```
|
||||
|
||||
Add column data:
|
||||
```html
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="job.store_code || job.store_name || '-'"></span>
|
||||
</td>
|
||||
```
|
||||
|
||||
#### 3.3 Update Schema
|
||||
|
||||
**File:** `models/schema/letzshop.py`
|
||||
|
||||
Update `LetzshopJobItem` to include store fields:
|
||||
```python
|
||||
store_id: int | None = None
|
||||
store_name: str | None = None
|
||||
store_code: str | None = None
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Harmonize Tables with Table Macro
|
||||
|
||||
### Current State
|
||||
- Different tables use different pagination styles
|
||||
- Some use simple prev/next, others use numbered
|
||||
- Inconsistent styling
|
||||
|
||||
### Requirements
|
||||
- All tables use `table` macro from `shared/macros/tables.html`
|
||||
- Numbered pagination with page numbers
|
||||
- Consistent column styling
|
||||
- Rows per page selector
|
||||
|
||||
### Tables to Update
|
||||
|
||||
| Table | File | Current Pagination |
|
||||
|-------|------|-------------------|
|
||||
| Jobs | letzshop-jobs-table.html | Simple prev/next |
|
||||
| Products | letzshop-products-tab.html | Simple prev/next |
|
||||
| Orders | letzshop-orders-tab.html | Simple prev/next |
|
||||
| Exceptions | letzshop-exceptions-tab.html | Simple prev/next |
|
||||
|
||||
### Implementation
|
||||
|
||||
#### 4.1 Create/Update Table Macro
|
||||
|
||||
**File:** `app/templates/shared/macros/tables.html`
|
||||
|
||||
Ensure numbered pagination macro exists:
|
||||
```html
|
||||
{% macro numbered_pagination(page_var, total_var, limit_var, on_change) %}
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<div class="text-sm text-gray-600">
|
||||
Showing <span x-text="(({{ page_var }} - 1) * {{ limit_var }}) + 1"></span>
|
||||
to <span x-text="Math.min({{ page_var }} * {{ limit_var }}, {{ total_var }})"></span>
|
||||
of <span x-text="{{ total_var }}"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- First -->
|
||||
<button @click="{{ page_var }} = 1; {{ on_change }}" :disabled="{{ page_var }} <= 1">«</button>
|
||||
<!-- Prev -->
|
||||
<button @click="{{ page_var }}--; {{ on_change }}" :disabled="{{ page_var }} <= 1">‹</button>
|
||||
<!-- Page numbers -->
|
||||
<template x-for="p in getPageNumbers({{ page_var }}, Math.ceil({{ total_var }} / {{ limit_var }}))">
|
||||
<button @click="{{ page_var }} = p; {{ on_change }}" :class="p === {{ page_var }} ? 'bg-purple-600 text-white' : ''">
|
||||
<span x-text="p"></span>
|
||||
</button>
|
||||
</template>
|
||||
<!-- Next -->
|
||||
<button @click="{{ page_var }}++; {{ on_change }}" :disabled="{{ page_var }} * {{ limit_var }} >= {{ total_var }}">›</button>
|
||||
<!-- Last -->
|
||||
<button @click="{{ page_var }} = Math.ceil({{ total_var }} / {{ limit_var }}); {{ on_change }}" :disabled="{{ page_var }} * {{ limit_var }} >= {{ total_var }}">»</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
```
|
||||
|
||||
#### 4.2 Add Page Numbers Helper to JavaScript
|
||||
|
||||
**File:** `static/shared/js/helpers.js` or inline
|
||||
|
||||
```javascript
|
||||
function getPageNumbers(current, total, maxVisible = 5) {
|
||||
if (total <= maxVisible) {
|
||||
return Array.from({length: total}, (_, i) => i + 1);
|
||||
}
|
||||
|
||||
const half = Math.floor(maxVisible / 2);
|
||||
let start = Math.max(1, current - half);
|
||||
let end = Math.min(total, start + maxVisible - 1);
|
||||
|
||||
if (end - start < maxVisible - 1) {
|
||||
start = Math.max(1, end - maxVisible + 1);
|
||||
}
|
||||
|
||||
return Array.from({length: end - start + 1}, (_, i) => start + i);
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.3 Update Each Table
|
||||
|
||||
Update each table to use the macro and consistent styling.
|
||||
|
||||
---
|
||||
|
||||
## 5. Platform-Wide Rows Per Page Setting
|
||||
|
||||
### Requirements
|
||||
- Global setting for default rows per page
|
||||
- Stored in platform settings (not per-user initially)
|
||||
- Used by all paginated tables
|
||||
- Options: 10, 20, 50, 100
|
||||
|
||||
### Implementation
|
||||
|
||||
#### 5.1 Add Platform Setting
|
||||
|
||||
**File:** `models/database/platform_settings.py` (create if doesn't exist)
|
||||
|
||||
```python
|
||||
class PlatformSettings(Base):
|
||||
__tablename__ = "platform_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
key = Column(String(100), unique=True, nullable=False)
|
||||
value = Column(String(500), nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow)
|
||||
```
|
||||
|
||||
Or add to existing settings table if one exists.
|
||||
|
||||
#### 5.2 Create Settings Service
|
||||
|
||||
**File:** `app/services/platform_settings_service.py`
|
||||
|
||||
```python
|
||||
class PlatformSettingsService:
|
||||
def get_setting(self, db: Session, key: str, default: Any = None) -> Any:
|
||||
setting = db.query(PlatformSettings).filter_by(key=key).first()
|
||||
return setting.value if setting else default
|
||||
|
||||
def set_setting(self, db: Session, key: str, value: Any) -> None:
|
||||
setting = db.query(PlatformSettings).filter_by(key=key).first()
|
||||
if setting:
|
||||
setting.value = str(value)
|
||||
else:
|
||||
setting = PlatformSettings(key=key, value=str(value))
|
||||
db.add(setting)
|
||||
db.flush()
|
||||
|
||||
def get_rows_per_page(self, db: Session) -> int:
|
||||
return int(self.get_setting(db, "rows_per_page", "20"))
|
||||
```
|
||||
|
||||
#### 5.3 Expose via API
|
||||
|
||||
**File:** `app/api/v1/admin/settings.py`
|
||||
|
||||
```python
|
||||
@router.get("/platform/rows-per-page")
|
||||
def get_rows_per_page(db: Session = Depends(get_db)):
|
||||
return {"rows_per_page": platform_settings_service.get_rows_per_page(db)}
|
||||
|
||||
@router.put("/platform/rows-per-page")
|
||||
def set_rows_per_page(
|
||||
rows: int = Query(..., ge=10, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
platform_settings_service.set_setting(db, "rows_per_page", rows)
|
||||
db.commit()
|
||||
return {"rows_per_page": rows}
|
||||
```
|
||||
|
||||
#### 5.4 Load Setting in Frontend
|
||||
|
||||
**File:** `static/shared/js/app.js` or similar
|
||||
|
||||
```javascript
|
||||
// Load platform settings on app init
|
||||
async function loadPlatformSettings() {
|
||||
try {
|
||||
const response = await apiClient.get('/admin/settings/platform/rows-per-page');
|
||||
window.platformSettings = {
|
||||
rowsPerPage: response.rows_per_page || 20
|
||||
};
|
||||
} catch {
|
||||
window.platformSettings = { rowsPerPage: 20 };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 5.5 Use in Alpine Components
|
||||
|
||||
```javascript
|
||||
// In each paginated component's init:
|
||||
this.limit = window.platformSettings?.rowsPerPage || 20;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Phase 1: Job Details Modal** (Quick win)
|
||||
- Add modal template
|
||||
- Update JS state and methods
|
||||
- Test with export jobs
|
||||
|
||||
2. **Phase 2: Store Column** (Preparation)
|
||||
- Update API response
|
||||
- Update schema
|
||||
- Add column to table
|
||||
|
||||
3. **Phase 3: Platform Settings** (Foundation)
|
||||
- Create settings model/migration
|
||||
- Create service
|
||||
- Create API endpoint
|
||||
- Frontend integration
|
||||
|
||||
4. **Phase 4: Table Harmonization** (Largest effort)
|
||||
- Create/update table macros
|
||||
- Add pagination helper function
|
||||
- Update each table one by one
|
||||
- Test thoroughly
|
||||
|
||||
5. **Phase 5: Documentation**
|
||||
- Update component documentation
|
||||
- Add settings documentation
|
||||
|
||||
---
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
### New Files
|
||||
- `models/database/platform_settings.py` (if not exists)
|
||||
- `app/services/platform_settings_service.py`
|
||||
- `alembic/versions/xxx_add_platform_settings.py`
|
||||
|
||||
### Modified Files
|
||||
- `app/templates/admin/partials/letzshop-jobs-table.html`
|
||||
- `app/templates/admin/partials/letzshop-products-tab.html`
|
||||
- `app/templates/admin/partials/letzshop-orders-tab.html`
|
||||
- `app/templates/admin/partials/letzshop-exceptions-tab.html`
|
||||
- `app/templates/shared/macros/tables.html`
|
||||
- `static/admin/js/marketplace-letzshop.js`
|
||||
- `static/shared/js/helpers.js` or `app.js`
|
||||
- `app/services/letzshop/order_service.py`
|
||||
- `models/schema/letzshop.py`
|
||||
- `app/api/v1/admin/settings.py` or new file
|
||||
|
||||
---
|
||||
|
||||
## 6. Admin Customer Page
|
||||
|
||||
### Requirements
|
||||
- New page at `/admin/customers` to manage customers
|
||||
- List all customers across stores
|
||||
- Search and filter capabilities
|
||||
- View customer details and order history
|
||||
- Link to store context
|
||||
|
||||
### Implementation
|
||||
|
||||
#### 6.1 Database Model Check
|
||||
|
||||
**File:** `models/database/customer.py`
|
||||
|
||||
Verify Customer model exists with fields:
|
||||
- id, store_id
|
||||
- email, name, phone
|
||||
- shipping address fields
|
||||
- created_at, updated_at
|
||||
|
||||
#### 6.2 Create Customer Service
|
||||
|
||||
**File:** `app/services/customer_service.py`
|
||||
|
||||
```python
|
||||
class CustomerService:
|
||||
def get_customers(
|
||||
self,
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
search: str | None = None,
|
||||
store_id: int | None = None,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Get paginated customer list with optional filters."""
|
||||
pass
|
||||
|
||||
def get_customer_detail(self, db: Session, customer_id: int) -> dict:
|
||||
"""Get customer with order history."""
|
||||
pass
|
||||
|
||||
def get_customer_stats(self, db: Session, store_id: int | None = None) -> dict:
|
||||
"""Get customer statistics."""
|
||||
pass
|
||||
```
|
||||
|
||||
#### 6.3 Create API Endpoints
|
||||
|
||||
**File:** `app/api/v1/admin/customers.py`
|
||||
|
||||
```python
|
||||
router = APIRouter(prefix="/customers")
|
||||
|
||||
@router.get("", response_model=CustomerListResponse)
|
||||
def get_customers(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
search: str | None = Query(None),
|
||||
store_id: int | None = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""List all customers with filtering."""
|
||||
pass
|
||||
|
||||
@router.get("/stats", response_model=CustomerStatsResponse)
|
||||
def get_customer_stats(...):
|
||||
"""Get customer statistics."""
|
||||
pass
|
||||
|
||||
@router.get("/{customer_id}", response_model=CustomerDetailResponse)
|
||||
def get_customer_detail(...):
|
||||
"""Get customer with order history."""
|
||||
pass
|
||||
```
|
||||
|
||||
#### 6.4 Create Pydantic Schemas
|
||||
|
||||
**File:** `models/schema/customer.py`
|
||||
|
||||
```python
|
||||
class CustomerListItem(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
name: str | None
|
||||
phone: str | None
|
||||
store_id: int
|
||||
store_name: str | None
|
||||
order_count: int
|
||||
total_spent: float
|
||||
created_at: datetime
|
||||
|
||||
class CustomerListResponse(BaseModel):
|
||||
customers: list[CustomerListItem]
|
||||
total: int
|
||||
skip: int
|
||||
limit: int
|
||||
|
||||
class CustomerDetailResponse(CustomerListItem):
|
||||
shipping_address: str | None
|
||||
orders: list[OrderSummary]
|
||||
|
||||
class CustomerStatsResponse(BaseModel):
|
||||
total: int
|
||||
new_this_month: int
|
||||
active: int # ordered in last 90 days
|
||||
by_store: dict[str, int]
|
||||
```
|
||||
|
||||
#### 6.5 Create Admin Page Route
|
||||
|
||||
**File:** `app/routes/admin_pages.py`
|
||||
|
||||
```python
|
||||
@router.get("/customers", response_class=HTMLResponse)
|
||||
async def admin_customers_page(request: Request, ...):
|
||||
return templates.TemplateResponse(
|
||||
"admin/customers.html",
|
||||
{"request": request, "current_page": "customers"}
|
||||
)
|
||||
```
|
||||
|
||||
#### 6.6 Create Template
|
||||
|
||||
**File:** `app/templates/admin/customers.html`
|
||||
|
||||
Structure:
|
||||
- Page header with title and stats
|
||||
- Search bar and filters (store dropdown)
|
||||
- Customer table with pagination
|
||||
- Click row to view details modal
|
||||
|
||||
#### 6.7 Create Alpine Component
|
||||
|
||||
**File:** `static/admin/js/customers.js`
|
||||
|
||||
```javascript
|
||||
function adminCustomers() {
|
||||
return {
|
||||
customers: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
search: '',
|
||||
storeFilter: '',
|
||||
loading: false,
|
||||
stats: {},
|
||||
|
||||
async init() {
|
||||
await Promise.all([
|
||||
this.loadCustomers(),
|
||||
this.loadStats()
|
||||
]);
|
||||
},
|
||||
|
||||
async loadCustomers() { ... },
|
||||
async loadStats() { ... },
|
||||
async viewCustomer(id) { ... },
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 6.8 Add to Sidebar
|
||||
|
||||
**File:** `app/templates/admin/partials/sidebar.html`
|
||||
|
||||
Add menu item:
|
||||
```html
|
||||
{{ menu_item('customers', '/admin/customers', 'users', 'Customers') }}
|
||||
```
|
||||
|
||||
### Customer Page Features
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| List View | Paginated table of all customers |
|
||||
| Search | Search by name, email, phone |
|
||||
| Store Filter | Filter by store |
|
||||
| Stats Cards | Total, new, active customers |
|
||||
| Detail Modal | Customer info + order history |
|
||||
| Quick Actions | View orders, send email |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Phase 1: Job Details Modal** (Quick win)
|
||||
- Add modal template
|
||||
- Update JS state and methods
|
||||
- Test with export jobs
|
||||
|
||||
2. **Phase 2: Store Column** (Preparation)
|
||||
- Update API response
|
||||
- Update schema
|
||||
- Add column to table
|
||||
|
||||
3. **Phase 3: Platform Settings** (Foundation)
|
||||
- Create settings model/migration
|
||||
- Create service
|
||||
- Create API endpoint
|
||||
- Frontend integration
|
||||
|
||||
4. **Phase 4: Table Harmonization** (Largest effort)
|
||||
- Create/update table macros
|
||||
- Add pagination helper function
|
||||
- Update each table one by one
|
||||
- Test thoroughly
|
||||
|
||||
5. **Phase 5: Admin Customer Page**
|
||||
- Create service and API
|
||||
- Create schemas
|
||||
- Create template and JS
|
||||
- Add to sidebar
|
||||
|
||||
6. **Phase 6: Documentation**
|
||||
- Update component documentation
|
||||
- Add settings documentation
|
||||
- Add customer page documentation
|
||||
|
||||
---
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
### New Files
|
||||
- `models/database/platform_settings.py` (if not exists)
|
||||
- `app/services/platform_settings_service.py`
|
||||
- `app/services/customer_service.py`
|
||||
- `app/api/v1/admin/customers.py`
|
||||
- `models/schema/customer.py`
|
||||
- `app/templates/admin/customers.html`
|
||||
- `static/admin/js/customers.js`
|
||||
- `alembic/versions/xxx_add_platform_settings.py`
|
||||
|
||||
### Modified Files
|
||||
- `app/templates/admin/partials/letzshop-jobs-table.html`
|
||||
- `app/templates/admin/partials/letzshop-products-tab.html`
|
||||
- `app/templates/admin/partials/letzshop-orders-tab.html`
|
||||
- `app/templates/admin/partials/letzshop-exceptions-tab.html`
|
||||
- `app/templates/admin/partials/sidebar.html`
|
||||
- `app/templates/shared/macros/tables.html`
|
||||
- `static/admin/js/marketplace-letzshop.js`
|
||||
- `static/shared/js/helpers.js` or `app.js`
|
||||
- `app/services/letzshop/order_service.py`
|
||||
- `models/schema/letzshop.py`
|
||||
- `app/api/v1/admin/__init__.py`
|
||||
- `app/routes/admin_pages.py`
|
||||
|
||||
---
|
||||
|
||||
## Estimated Effort
|
||||
|
||||
| Task | Effort |
|
||||
|------|--------|
|
||||
| Job Details Modal | Small |
|
||||
| Tab Visibility (no change) | None |
|
||||
| Store Column | Small |
|
||||
| Platform Settings | Medium |
|
||||
| Table Harmonization | Large |
|
||||
| Admin Customer Page | Medium |
|
||||
|
||||
**Total:** Large effort
|
||||
|
||||
---
|
||||
|
||||
*Plan created: 2024-12-20*
|
||||
This document has moved to the marketplace module docs: [Job Queue](../modules/marketplace/job-queue.md)
|
||||
|
||||
@@ -1,601 +1,3 @@
|
||||
# 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 |
|
||||
This document has moved to the marketplace module docs: [Import Improvements](../modules/marketplace/import-improvements.md)
|
||||
|
||||
@@ -1,243 +1 @@
|
||||
# Messaging System Implementation
|
||||
|
||||
This document describes the messaging system that enables threaded conversations between different platform participants.
|
||||
|
||||
## Overview
|
||||
|
||||
The messaging system supports three communication channels:
|
||||
|
||||
1. **Admin <-> Store**: Platform administrators communicate with store users
|
||||
2. **Store <-> Customer**: Stores communicate with their customers
|
||||
3. **Admin <-> Customer**: Platform administrators communicate with customers
|
||||
|
||||
## Architecture
|
||||
|
||||
### Database Models
|
||||
|
||||
Located in `models/database/message.py`:
|
||||
|
||||
| Model | Description |
|
||||
|-------|-------------|
|
||||
| `Conversation` | Threaded conversation container with subject, type, and status |
|
||||
| `ConversationParticipant` | Links participants to conversations with unread tracking |
|
||||
| `Message` | Individual messages within a conversation |
|
||||
| `MessageAttachment` | File attachments for messages |
|
||||
|
||||
### Enums
|
||||
|
||||
| Enum | Values | Description |
|
||||
|------|--------|-------------|
|
||||
| `ConversationType` | `admin_store`, `store_customer`, `admin_customer` | Defines conversation channel |
|
||||
| `ParticipantType` | `admin`, `store`, `customer` | Type of participant |
|
||||
|
||||
### Polymorphic Participants
|
||||
|
||||
The system uses polymorphic relationships via `participant_type` + `participant_id`:
|
||||
- `admin` and `store` types reference `users.id`
|
||||
- `customer` type references `customers.id`
|
||||
|
||||
### Multi-Tenant Isolation
|
||||
|
||||
Conversations involving customers include a `store_id` to ensure proper data isolation. Store users can only see conversations within their store context.
|
||||
|
||||
## Services
|
||||
|
||||
### MessagingService (`app/services/messaging_service.py`)
|
||||
|
||||
Core business logic for conversations and messages:
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `create_conversation()` | Create a new conversation with participants |
|
||||
| `get_conversation()` | Get conversation with access validation |
|
||||
| `list_conversations()` | Paginated list with filters |
|
||||
| `send_message()` | Send message with automatic unread updates |
|
||||
| `mark_conversation_read()` | Mark all messages read for participant |
|
||||
| `get_unread_count()` | Get total unread count for header badge |
|
||||
| `close_conversation()` | Close a conversation thread |
|
||||
| `reopen_conversation()` | Reopen a closed conversation |
|
||||
|
||||
### MessageAttachmentService (`app/services/message_attachment_service.py`)
|
||||
|
||||
File upload handling:
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `validate_and_store()` | Validate file type/size and store to disk |
|
||||
| `get_max_file_size_bytes()` | Get limit from platform settings |
|
||||
| `delete_attachment()` | Remove files from storage |
|
||||
|
||||
**Allowed file types:**
|
||||
- Images: JPEG, PNG, GIF, WebP
|
||||
- Documents: PDF, Office documents
|
||||
- Archives: ZIP
|
||||
- Text: Plain text, CSV
|
||||
|
||||
**Storage path pattern:** `uploads/messages/YYYY/MM/conversation_id/uuid.ext`
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Admin API (`/api/v1/admin/messages`)
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/messages` | GET | List conversations |
|
||||
| `/messages` | POST | Create conversation |
|
||||
| `/messages/unread-count` | GET | Get unread badge count |
|
||||
| `/messages/recipients` | GET | Get available recipients |
|
||||
| `/messages/{id}` | GET | Get conversation detail |
|
||||
| `/messages/{id}/messages` | POST | Send message (with attachments) |
|
||||
| `/messages/{id}/close` | POST | Close conversation |
|
||||
| `/messages/{id}/reopen` | POST | Reopen conversation |
|
||||
| `/messages/{id}/read` | PUT | Mark as read |
|
||||
| `/messages/{id}/preferences` | PUT | Update notification preferences |
|
||||
|
||||
### Store API (`/api/v1/store/messages`)
|
||||
|
||||
Same structure as admin, but with store context filtering. Stores can only:
|
||||
- See their own store_customer and admin_store conversations
|
||||
- Create store_customer conversations with their customers
|
||||
- Not initiate admin_store conversations (admins initiate those)
|
||||
|
||||
## Frontend
|
||||
|
||||
### Admin Interface
|
||||
|
||||
- **Template:** `app/templates/admin/messages.html`
|
||||
- **JavaScript:** `static/admin/js/messages.js`
|
||||
|
||||
Features:
|
||||
- Split-panel conversation list + message thread
|
||||
- Filters by type (stores/customers) and status (open/closed)
|
||||
- Compose modal for new conversations
|
||||
- File attachment support
|
||||
- 30-second polling for new messages
|
||||
- Header badge with unread count
|
||||
|
||||
### Store Interface
|
||||
|
||||
- **Template:** `app/templates/store/messages.html`
|
||||
- **JavaScript:** `static/store/js/messages.js`
|
||||
|
||||
Similar to admin but with store-specific:
|
||||
- Only store_customer and admin_store channels
|
||||
- Compose modal for customer conversations only
|
||||
|
||||
## Pydantic Schemas
|
||||
|
||||
Located in `models/schema/message.py`:
|
||||
|
||||
- `ConversationCreate` - Create request
|
||||
- `ConversationSummary` - List item with unread count
|
||||
- `ConversationDetailResponse` - Full thread with messages
|
||||
- `ConversationListResponse` - Paginated list
|
||||
- `MessageResponse` - Single message with attachments
|
||||
- `AttachmentResponse` - File metadata with download URL
|
||||
- `UnreadCountResponse` - For header badge
|
||||
|
||||
## Configuration
|
||||
|
||||
### Platform Setting
|
||||
|
||||
The attachment size limit is configurable via platform settings:
|
||||
|
||||
- **Key:** `message_attachment_max_size_mb`
|
||||
- **Default:** 10
|
||||
- **Category:** messaging
|
||||
|
||||
## Storefront (Customer) Interface
|
||||
|
||||
### API Endpoints (`/api/v1/storefront/messages`)
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/messages` | GET | List customer's conversations |
|
||||
| `/messages/unread-count` | GET | Get unread badge count |
|
||||
| `/messages/{id}` | GET | Get conversation detail |
|
||||
| `/messages/{id}/messages` | POST | Send reply message |
|
||||
| `/messages/{id}/read` | PUT | Mark as read |
|
||||
| `/messages/{id}/attachments/{att_id}` | GET | Download attachment |
|
||||
|
||||
### Frontend
|
||||
|
||||
- **Template:** `app/templates/storefront/account/messages.html`
|
||||
- **Page Route:** `/storefront/account/messages` and `/storefront/account/messages/{conversation_id}`
|
||||
|
||||
Features:
|
||||
- Conversation list with unread badges
|
||||
- Filter by status (open/closed)
|
||||
- Thread view with message history
|
||||
- Reply form with file attachments
|
||||
- 30-second polling for new messages
|
||||
- Link from account dashboard with unread count
|
||||
|
||||
### Limitations
|
||||
|
||||
Customers can only:
|
||||
- View their `store_customer` conversations
|
||||
- Reply to existing conversations (cannot initiate)
|
||||
- Cannot close conversations
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Email Notifications (Requires Email Infrastructure)
|
||||
|
||||
The messaging system is designed to support email notifications, but requires email infrastructure to be implemented first:
|
||||
|
||||
**Prerequisites:**
|
||||
- SMTP configuration in settings (host, port, username, password)
|
||||
- Email service (`app/services/email_service.py`)
|
||||
- Email templates (`app/templates/emails/`)
|
||||
- Background task queue for async sending
|
||||
|
||||
**Planned Implementation:**
|
||||
1. **MessageNotificationService** (`app/services/message_notification_service.py`)
|
||||
- `notify_new_message()` - Send email to participants on new message
|
||||
- Respect per-conversation `email_notifications` preference
|
||||
- Include message preview and reply link
|
||||
|
||||
2. **Email Template** (`app/templates/emails/new_message.html`)
|
||||
- Subject: "New message: {conversation_subject}"
|
||||
- Body: Sender name, message preview, link to reply
|
||||
|
||||
3. **Integration Points:**
|
||||
- Call `notify_new_message()` from `messaging_service.send_message()`
|
||||
- Skip notification for sender (only notify other participants)
|
||||
- Rate limit to prevent spam on rapid message exchanges
|
||||
|
||||
**Database Support:**
|
||||
The `email_notifications` field on `ConversationParticipant` is already in place to store per-conversation preferences.
|
||||
|
||||
### WebSocket Support (Optional)
|
||||
|
||||
Real-time message delivery instead of 30-second polling:
|
||||
- Would require WebSocket infrastructure (e.g., FastAPI WebSocket, Redis pub/sub)
|
||||
- Significant infrastructure changes
|
||||
|
||||
## Migration
|
||||
|
||||
The messaging tables are created by migration `e3f4a5b6c7d8_add_messaging_tables.py`:
|
||||
|
||||
```bash
|
||||
# Apply migration
|
||||
alembic upgrade head
|
||||
|
||||
# Rollback
|
||||
alembic downgrade -1
|
||||
```
|
||||
|
||||
## Navigation
|
||||
|
||||
### Admin Sidebar
|
||||
Messages is available under "Platform Administration" section.
|
||||
|
||||
### Store Sidebar
|
||||
Messages is available under "Sales" section.
|
||||
|
||||
### Storefront Account Dashboard
|
||||
Messages card is available on the customer account dashboard with unread count badge.
|
||||
|
||||
### Header Badge
|
||||
Both admin and store headers show an unread message count badge next to the messages icon.
|
||||
This document has moved to the messaging module docs: [Messaging Architecture](../modules/messaging/architecture.md)
|
||||
|
||||
@@ -1,662 +1 @@
|
||||
# OMS Feature Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Transform Orion into a **"Lightweight OMS for Letzshop Sellers"** by building the missing features that justify the tier pricing structure.
|
||||
|
||||
**Goal:** Ship Essential tier quickly, then build Professional differentiators, then Business features.
|
||||
|
||||
## Design Decisions (Confirmed)
|
||||
|
||||
| Decision | Choice |
|
||||
|----------|--------|
|
||||
| Phase 1 scope | Invoicing + Tier Limits together |
|
||||
| PDF library | WeasyPrint (HTML/CSS to PDF) |
|
||||
| Invoice style | Simple & Clean (minimal design) |
|
||||
|
||||
---
|
||||
|
||||
## Current State Summary
|
||||
|
||||
### Already Production-Ready
|
||||
- Multi-tenant architecture (Merchant → Store hierarchy)
|
||||
- Letzshop order sync, confirmation, tracking
|
||||
- Inventory management with locations and reservations
|
||||
- Unified Order model (direct + marketplace)
|
||||
- Customer model with pre-calculated stats (total_orders, total_spent)
|
||||
- Team management + RBAC
|
||||
- CSV export patterns (products)
|
||||
|
||||
### Needs to be Built
|
||||
| Feature | Tier Impact | Priority |
|
||||
|---------|-------------|----------|
|
||||
| Basic LU Invoice (PDF) | Essential | P0 |
|
||||
| Tier limits enforcement | Essential | P0 |
|
||||
| Store VAT Settings | Professional | P1 |
|
||||
| EU VAT Invoice | Professional | P1 |
|
||||
| Incoming Stock / PO | Professional | P1 |
|
||||
| Customer CSV Export | Professional | P1 |
|
||||
| Multi-store view | Business | P2 |
|
||||
| Accounting export | Business | P2 |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Essential Tier (Target: 1 week)
|
||||
|
||||
**Goal:** Launch Essential (€49) with basic invoicing and tier enforcement.
|
||||
|
||||
### Step 1.1: Store Invoice Settings (1 day)
|
||||
|
||||
**Create model for store billing details:**
|
||||
|
||||
```
|
||||
models/database/store_invoice_settings.py
|
||||
```
|
||||
|
||||
Fields:
|
||||
- `store_id` (FK, unique - one-to-one)
|
||||
- `merchant_name` (legal name for invoices)
|
||||
- `merchant_address`, `merchant_city`, `merchant_postal_code`, `merchant_country`
|
||||
- `vat_number` (e.g., "LU12345678")
|
||||
- `invoice_prefix` (default "INV")
|
||||
- `invoice_next_number` (auto-increment)
|
||||
- `payment_terms` (optional text)
|
||||
- `bank_details` (optional IBAN etc.)
|
||||
- `footer_text` (optional)
|
||||
|
||||
**Pattern to follow:** `models/database/letzshop.py` (StoreLetzshopCredentials)
|
||||
|
||||
**Files to create/modify:**
|
||||
- `models/database/store_invoice_settings.py` (new)
|
||||
- `models/database/__init__.py` (add import)
|
||||
- `models/database/store.py` (add relationship)
|
||||
- `models/schema/invoice.py` (new - Pydantic schemas)
|
||||
- `alembic/versions/xxx_add_store_invoice_settings.py` (migration)
|
||||
|
||||
---
|
||||
|
||||
### Step 1.2: Basic Invoice Model (0.5 day)
|
||||
|
||||
**Create invoice storage:**
|
||||
|
||||
```
|
||||
models/database/invoice.py
|
||||
```
|
||||
|
||||
Fields:
|
||||
- `id`, `store_id` (FK)
|
||||
- `order_id` (FK, nullable - for manual invoices later)
|
||||
- `invoice_number` (unique per store)
|
||||
- `invoice_date`
|
||||
- `seller_details` (JSONB snapshot)
|
||||
- `buyer_details` (JSONB snapshot)
|
||||
- `line_items` (JSONB snapshot)
|
||||
- `subtotal_cents`, `vat_rate`, `vat_amount_cents`, `total_cents`
|
||||
- `currency` (default EUR)
|
||||
- `status` (draft, issued, paid, cancelled)
|
||||
- `pdf_generated_at`, `pdf_path` (optional)
|
||||
|
||||
**Files to create/modify:**
|
||||
- `models/database/invoice.py` (new)
|
||||
- `models/database/__init__.py` (add import)
|
||||
- `alembic/versions/xxx_add_invoices_table.py` (migration)
|
||||
|
||||
---
|
||||
|
||||
### Step 1.3: Invoice Service - Basic LU Only (1 day)
|
||||
|
||||
**Create service for invoice generation:**
|
||||
|
||||
```
|
||||
app/services/invoice_service.py
|
||||
```
|
||||
|
||||
Methods:
|
||||
- `create_invoice_from_order(order_id, store_id)` - Generate invoice from order
|
||||
- `get_invoice(invoice_id, store_id)` - Retrieve invoice
|
||||
- `list_invoices(store_id, skip, limit)` - List store invoices
|
||||
- `_generate_invoice_number(settings)` - Auto-increment number
|
||||
- `_snapshot_seller(settings)` - Capture store details
|
||||
- `_snapshot_buyer(order)` - Capture customer details
|
||||
- `_calculate_totals(order)` - Calculate with LU VAT (17%)
|
||||
|
||||
**For Essential tier:** Fixed 17% Luxembourg VAT only. EU VAT comes in Professional.
|
||||
|
||||
**Files to create:**
|
||||
- `app/services/invoice_service.py` (new)
|
||||
|
||||
---
|
||||
|
||||
### Step 1.4: PDF Generation (1.5 days)
|
||||
|
||||
**Add WeasyPrint dependency and create PDF service:**
|
||||
|
||||
```
|
||||
app/services/invoice_pdf_service.py
|
||||
```
|
||||
|
||||
Methods:
|
||||
- `generate_pdf(invoice)` - Returns PDF bytes
|
||||
- `_render_html(invoice)` - Jinja2 template rendering
|
||||
|
||||
**Template:**
|
||||
```
|
||||
app/templates/invoices/invoice.html
|
||||
```
|
||||
|
||||
Simple, clean invoice layout:
|
||||
- Seller details (top left)
|
||||
- Buyer details (top right)
|
||||
- Invoice number + date
|
||||
- Line items table
|
||||
- Totals with VAT breakdown
|
||||
- Footer (payment terms, bank details)
|
||||
|
||||
**Files to create/modify:**
|
||||
- `requirements.txt` (add weasyprint)
|
||||
- `app/services/invoice_pdf_service.py` (new)
|
||||
- `app/templates/invoices/invoice.html` (new)
|
||||
- `app/templates/invoices/invoice.css` (new, optional)
|
||||
|
||||
---
|
||||
|
||||
### Step 1.5: Invoice API Endpoints (0.5 day)
|
||||
|
||||
**Create store invoice endpoints:**
|
||||
|
||||
```
|
||||
app/api/v1/store/invoices.py
|
||||
```
|
||||
|
||||
Endpoints:
|
||||
- `POST /orders/{order_id}/invoice` - Generate invoice for order
|
||||
- `GET /invoices` - List invoices
|
||||
- `GET /invoices/{invoice_id}` - Get invoice details
|
||||
- `GET /invoices/{invoice_id}/pdf` - Download PDF
|
||||
|
||||
**Files to create/modify:**
|
||||
- `app/api/v1/store/invoices.py` (new)
|
||||
- `app/api/v1/store/__init__.py` (add router)
|
||||
|
||||
---
|
||||
|
||||
### Step 1.6: Invoice Settings UI (0.5 day)
|
||||
|
||||
**Add invoice settings to store settings page:**
|
||||
|
||||
Modify existing store settings template to add "Invoice Settings" section:
|
||||
- Merchant name, address fields
|
||||
- VAT number
|
||||
- Invoice prefix
|
||||
- Payment terms
|
||||
- Bank details
|
||||
|
||||
**Files to modify:**
|
||||
- `app/templates/store/settings.html` (add section)
|
||||
- `static/store/js/settings.js` (add handlers)
|
||||
- `app/api/v1/store/settings.py` (add endpoints if needed)
|
||||
|
||||
---
|
||||
|
||||
### Step 1.7: Order Detail - Invoice Button (0.5 day)
|
||||
|
||||
**Add "Generate Invoice" / "Download Invoice" button to order detail:**
|
||||
|
||||
- If no invoice: Show "Generate Invoice" button
|
||||
- If invoice exists: Show "Download Invoice" link
|
||||
|
||||
**Files to modify:**
|
||||
- `app/templates/store/order-detail.html` (add button)
|
||||
- `static/store/js/order-detail.js` (add handler)
|
||||
|
||||
---
|
||||
|
||||
### Step 1.8: Tier Limits Enforcement (1 day)
|
||||
|
||||
**Create tier/subscription model:**
|
||||
|
||||
```
|
||||
models/database/store_subscription.py
|
||||
```
|
||||
|
||||
Fields:
|
||||
- `store_id` (FK, unique)
|
||||
- `tier` (essential, professional, business)
|
||||
- `orders_this_month` (counter, reset monthly)
|
||||
- `period_start`, `period_end`
|
||||
- `is_active`
|
||||
|
||||
**Create limits service:**
|
||||
|
||||
```
|
||||
app/services/tier_limits_service.py
|
||||
```
|
||||
|
||||
Methods:
|
||||
- `check_order_limit(store_id)` - Returns (allowed: bool, remaining: int)
|
||||
- `increment_order_count(store_id)` - Called when order synced
|
||||
- `get_tier_limits(tier)` - Returns limit config
|
||||
- `reset_monthly_counters()` - Cron job
|
||||
|
||||
**Tier limits:**
|
||||
| Tier | Orders/month | Products |
|
||||
|------|--------------|----------|
|
||||
| Essential | 100 | 200 |
|
||||
| Professional | 500 | Unlimited |
|
||||
| Business | Unlimited | Unlimited |
|
||||
|
||||
**Integration points:**
|
||||
- `order_service.py` - Check limit before creating order
|
||||
- Letzshop sync - Check limit before importing
|
||||
|
||||
**Files to create/modify:**
|
||||
- `models/database/store_subscription.py` (new)
|
||||
- `app/services/tier_limits_service.py` (new)
|
||||
- `app/services/order_service.py` (add limit check)
|
||||
- `app/services/letzshop/order_service.py` (add limit check)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Professional Tier (Target: 2 weeks)
|
||||
|
||||
**Goal:** Build the differentiating features that justify €99/month.
|
||||
|
||||
### Step 2.1: EU VAT Rates Table (0.5 day)
|
||||
|
||||
**Create VAT rates reference table:**
|
||||
|
||||
```
|
||||
models/database/eu_vat_rates.py
|
||||
```
|
||||
|
||||
Fields:
|
||||
- `country_code` (LU, DE, FR, etc.)
|
||||
- `country_name`
|
||||
- `standard_rate` (decimal)
|
||||
- `reduced_rate_1`, `reduced_rate_2` (optional)
|
||||
- `effective_from`, `effective_until`
|
||||
|
||||
**Seed with current EU rates (27 countries).**
|
||||
|
||||
**Files to create:**
|
||||
- `models/database/eu_vat_rates.py` (new)
|
||||
- `alembic/versions/xxx_add_eu_vat_rates.py` (migration + seed)
|
||||
|
||||
---
|
||||
|
||||
### Step 2.2: Enhanced Store VAT Settings (0.5 day)
|
||||
|
||||
**Add OSS fields to StoreInvoiceSettings:**
|
||||
|
||||
- `is_oss_registered` (boolean)
|
||||
- `oss_registration_country` (if different from merchant country)
|
||||
|
||||
**Files to modify:**
|
||||
- `models/database/store_invoice_settings.py` (add fields)
|
||||
- `alembic/versions/xxx_add_oss_fields.py` (migration)
|
||||
|
||||
---
|
||||
|
||||
### Step 2.3: VAT Service (1 day)
|
||||
|
||||
**Create VAT calculation service:**
|
||||
|
||||
```
|
||||
app/services/vat_service.py
|
||||
```
|
||||
|
||||
Methods:
|
||||
- `get_vat_rate(country_code, as_of_date)` - Lookup rate
|
||||
- `determine_vat_regime(seller_country, buyer_country, buyer_vat_number, is_oss)` - Returns (regime, rate)
|
||||
- `validate_vat_number(vat_number)` - Format check (VIES integration later)
|
||||
|
||||
**VAT Decision Logic:**
|
||||
1. B2B with valid VAT number → Reverse charge (0%)
|
||||
2. Domestic sale → Domestic VAT
|
||||
3. Cross-border + OSS registered → Destination VAT
|
||||
4. Cross-border + under threshold → Origin VAT
|
||||
|
||||
**Files to create:**
|
||||
- `app/services/vat_service.py` (new)
|
||||
|
||||
---
|
||||
|
||||
### Step 2.4: Enhanced Invoice Service (1 day)
|
||||
|
||||
**Upgrade invoice service for EU VAT:**
|
||||
|
||||
- Add `vat_regime` field to invoice (domestic, oss, reverse_charge, origin)
|
||||
- Add `destination_country` field
|
||||
- Use VATService to calculate correct rate
|
||||
- Update invoice template for regime-specific text
|
||||
|
||||
**Files to modify:**
|
||||
- `models/database/invoice.py` (add fields)
|
||||
- `app/services/invoice_service.py` (use VATService)
|
||||
- `app/templates/invoices/invoice.html` (add regime text)
|
||||
- `alembic/versions/xxx_add_vat_regime_to_invoices.py`
|
||||
|
||||
---
|
||||
|
||||
### Step 2.5: Purchase Order Model (1 day)
|
||||
|
||||
**Create purchase order tracking:**
|
||||
|
||||
```
|
||||
models/database/purchase_order.py
|
||||
```
|
||||
|
||||
**PurchaseOrder:**
|
||||
- `id`, `store_id` (FK)
|
||||
- `po_number` (auto-generated)
|
||||
- `supplier_name` (free text for now)
|
||||
- `status` (draft, ordered, partial, received, cancelled)
|
||||
- `order_date`, `expected_date`
|
||||
- `notes`
|
||||
|
||||
**PurchaseOrderItem:**
|
||||
- `purchase_order_id` (FK)
|
||||
- `product_id` (FK)
|
||||
- `quantity_ordered`
|
||||
- `quantity_received`
|
||||
- `unit_cost_cents` (optional)
|
||||
|
||||
**Files to create:**
|
||||
- `models/database/purchase_order.py` (new)
|
||||
- `models/database/__init__.py` (add import)
|
||||
- `models/schema/purchase_order.py` (new)
|
||||
- `alembic/versions/xxx_add_purchase_orders.py`
|
||||
|
||||
---
|
||||
|
||||
### Step 2.6: Purchase Order Service (1 day)
|
||||
|
||||
**Create PO management service:**
|
||||
|
||||
```
|
||||
app/services/purchase_order_service.py
|
||||
```
|
||||
|
||||
Methods:
|
||||
- `create_purchase_order(store_id, data)` - Create PO
|
||||
- `add_item(po_id, product_id, quantity)` - Add line item
|
||||
- `receive_items(po_id, items)` - Mark items received, update inventory
|
||||
- `get_incoming_stock(store_id)` - Summary of pending stock
|
||||
- `list_purchase_orders(store_id, status, skip, limit)`
|
||||
|
||||
**Integration:** When items received → call `inventory_service.adjust_inventory()`
|
||||
|
||||
**Files to create:**
|
||||
- `app/services/purchase_order_service.py` (new)
|
||||
|
||||
---
|
||||
|
||||
### Step 2.7: Purchase Order UI (1.5 days)
|
||||
|
||||
**Create PO management page:**
|
||||
|
||||
```
|
||||
app/templates/store/purchase-orders.html
|
||||
```
|
||||
|
||||
Features:
|
||||
- List POs with status
|
||||
- Create new PO (select products, quantities, expected date)
|
||||
- Receive items (partial or full)
|
||||
- View incoming stock summary
|
||||
|
||||
**Inventory page enhancement:**
|
||||
- Show "On Order" column in inventory list
|
||||
- Query: SUM of quantity_ordered - quantity_received for pending POs
|
||||
|
||||
**Files to create/modify:**
|
||||
- `app/templates/store/purchase-orders.html` (new)
|
||||
- `static/store/js/purchase-orders.js` (new)
|
||||
- `app/api/v1/store/purchase_orders.py` (new endpoints)
|
||||
- `app/routes/store_pages.py` (add route)
|
||||
- `app/templates/store/partials/sidebar.html` (add menu item)
|
||||
- `app/templates/store/inventory.html` (add On Order column)
|
||||
|
||||
---
|
||||
|
||||
### Step 2.8: Customer Export Service (1 day)
|
||||
|
||||
**Create customer export functionality:**
|
||||
|
||||
```
|
||||
app/services/customer_export_service.py
|
||||
```
|
||||
|
||||
Methods:
|
||||
- `export_customers_csv(store_id, filters)` - Returns CSV string
|
||||
|
||||
**CSV Columns:**
|
||||
- email, first_name, last_name, phone
|
||||
- customer_number
|
||||
- total_orders, total_spent, avg_order_value
|
||||
- first_order_date, last_order_date
|
||||
- preferred_language
|
||||
- marketing_consent
|
||||
- tags (if we add tagging)
|
||||
|
||||
**Files to create:**
|
||||
- `app/services/customer_export_service.py` (new)
|
||||
|
||||
---
|
||||
|
||||
### Step 2.9: Customer Export API + UI (0.5 day)
|
||||
|
||||
**Add export endpoint:**
|
||||
|
||||
```
|
||||
GET /api/v1/store/customers/export?format=csv
|
||||
```
|
||||
|
||||
**Add export button to customers page:**
|
||||
- "Export to CSV" button
|
||||
- Downloads file directly
|
||||
|
||||
**Files to modify:**
|
||||
- `app/api/v1/store/customers.py` (add export endpoint)
|
||||
- `app/templates/store/customers.html` (add button)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Business Tier (Target: 1-2 weeks)
|
||||
|
||||
**Goal:** Build features for teams and high-volume operations.
|
||||
|
||||
### Step 3.1: Multi-Store Consolidated View (2 days)
|
||||
|
||||
**For merchants with multiple Letzshop accounts:**
|
||||
|
||||
**New page:**
|
||||
```
|
||||
app/templates/store/multi-store-dashboard.html
|
||||
```
|
||||
|
||||
Features:
|
||||
- See all store accounts under same merchant
|
||||
- Consolidated order count, revenue
|
||||
- Switch between store contexts
|
||||
- Unified reporting
|
||||
|
||||
**Requires:** Merchant-level authentication context (already exists via Merchant → Store relationship)
|
||||
|
||||
**Files to create/modify:**
|
||||
- `app/templates/store/multi-store-dashboard.html` (new)
|
||||
- `app/services/multi_store_service.py` (new)
|
||||
- `app/api/v1/store/multi_store.py` (new)
|
||||
|
||||
---
|
||||
|
||||
### Step 3.2: Accounting Export (1 day)
|
||||
|
||||
**Export invoices in accounting-friendly formats:**
|
||||
|
||||
```
|
||||
app/services/accounting_export_service.py
|
||||
```
|
||||
|
||||
Methods:
|
||||
- `export_invoices_csv(store_id, date_from, date_to)` - Simple CSV
|
||||
- `export_invoices_xml(store_id, date_from, date_to)` - For accounting software
|
||||
|
||||
**CSV format for accountants:**
|
||||
- invoice_number, invoice_date
|
||||
- customer_name, customer_vat
|
||||
- subtotal, vat_rate, vat_amount, total
|
||||
- currency, status
|
||||
|
||||
**Files to create:**
|
||||
- `app/services/accounting_export_service.py` (new)
|
||||
- `app/api/v1/store/accounting.py` (new endpoints)
|
||||
|
||||
---
|
||||
|
||||
### Step 3.3: API Access Documentation (1 day)
|
||||
|
||||
**If not already documented, create API documentation page:**
|
||||
|
||||
- Document existing store API endpoints
|
||||
- Add rate limiting for API tier
|
||||
- Generate API keys for stores
|
||||
|
||||
**Files to create/modify:**
|
||||
- `docs/api/store-api.md` (documentation)
|
||||
- `app/services/api_key_service.py` (if needed)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order Summary
|
||||
|
||||
### Week 1: Essential Tier
|
||||
| Day | Task | Deliverable |
|
||||
|-----|------|-------------|
|
||||
| 1 | Step 1.1 | Store Invoice Settings model |
|
||||
| 1 | Step 1.2 | Invoice model |
|
||||
| 2 | Step 1.3 | Invoice Service (LU only) |
|
||||
| 3-4 | Step 1.4 | PDF Generation |
|
||||
| 4 | Step 1.5 | Invoice API |
|
||||
| 5 | Step 1.6 | Invoice Settings UI |
|
||||
| 5 | Step 1.7 | Order Detail button |
|
||||
|
||||
### Week 2: Tier Limits + EU VAT Start
|
||||
| Day | Task | Deliverable |
|
||||
|-----|------|-------------|
|
||||
| 1 | Step 1.8 | Tier limits enforcement |
|
||||
| 2 | Step 2.1 | EU VAT rates table |
|
||||
| 2 | Step 2.2 | OSS fields |
|
||||
| 3 | Step 2.3 | VAT Service |
|
||||
| 4 | Step 2.4 | Enhanced Invoice Service |
|
||||
| 5 | Testing | End-to-end invoice testing |
|
||||
|
||||
### Week 3: Purchase Orders + Customer Export
|
||||
| Day | Task | Deliverable |
|
||||
|-----|------|-------------|
|
||||
| 1 | Step 2.5 | Purchase Order model |
|
||||
| 2 | Step 2.6 | Purchase Order service |
|
||||
| 3-4 | Step 2.7 | Purchase Order UI |
|
||||
| 5 | Step 2.8-2.9 | Customer Export |
|
||||
|
||||
### Week 4: Business Tier
|
||||
| Day | Task | Deliverable |
|
||||
|-----|------|-------------|
|
||||
| 1-2 | Step 3.1 | Multi-store view |
|
||||
| 3 | Step 3.2 | Accounting export |
|
||||
| 4 | Step 3.3 | API documentation |
|
||||
| 5 | Testing + Polish | Full testing |
|
||||
|
||||
---
|
||||
|
||||
## Key Files Reference
|
||||
|
||||
### Models to Create
|
||||
- `models/database/store_invoice_settings.py`
|
||||
- `models/database/invoice.py`
|
||||
- `models/database/eu_vat_rates.py`
|
||||
- `models/database/store_subscription.py`
|
||||
- `models/database/purchase_order.py`
|
||||
|
||||
### Services to Create
|
||||
- `app/services/invoice_service.py`
|
||||
- `app/services/invoice_pdf_service.py`
|
||||
- `app/services/vat_service.py`
|
||||
- `app/services/tier_limits_service.py`
|
||||
- `app/services/purchase_order_service.py`
|
||||
- `app/services/customer_export_service.py`
|
||||
- `app/services/accounting_export_service.py`
|
||||
|
||||
### Templates to Create
|
||||
- `app/templates/invoices/invoice.html`
|
||||
- `app/templates/store/purchase-orders.html`
|
||||
|
||||
### Existing Files to Modify
|
||||
- `models/database/__init__.py`
|
||||
- `models/database/store.py`
|
||||
- `app/services/order_service.py`
|
||||
- `app/templates/store/settings.html`
|
||||
- `app/templates/store/order-detail.html`
|
||||
- `app/templates/store/inventory.html`
|
||||
- `app/templates/store/customers.html`
|
||||
- `requirements.txt`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies to Add
|
||||
|
||||
```
|
||||
# requirements.txt
|
||||
weasyprint>=60.0
|
||||
```
|
||||
|
||||
**Note:** WeasyPrint requires system dependencies:
|
||||
- `libpango-1.0-0`
|
||||
- `libpangocairo-1.0-0`
|
||||
- `libgdk-pixbuf2.0-0`
|
||||
|
||||
Add to Dockerfile if deploying via Docker.
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- `tests/unit/services/test_invoice_service.py`
|
||||
- `tests/unit/services/test_vat_service.py`
|
||||
- `tests/unit/services/test_tier_limits_service.py`
|
||||
- `tests/unit/services/test_purchase_order_service.py`
|
||||
|
||||
### Integration Tests
|
||||
- `tests/integration/api/v1/store/test_invoices.py`
|
||||
- `tests/integration/api/v1/store/test_purchase_orders.py`
|
||||
|
||||
### Manual Testing
|
||||
- Generate invoice for LU customer
|
||||
- Generate invoice for DE customer (OSS)
|
||||
- Generate invoice for B2B with VAT number (reverse charge)
|
||||
- Create PO, receive items, verify inventory update
|
||||
- Export customers CSV, import to Mailchimp
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Essential Tier Ready When:
|
||||
- [ ] Can generate PDF invoice from order (LU VAT)
|
||||
- [ ] Invoice settings page works
|
||||
- [ ] Order detail shows invoice button
|
||||
- [ ] Tier limits enforced on order sync
|
||||
|
||||
### Professional Tier Ready When:
|
||||
- [ ] EU VAT calculated correctly by destination
|
||||
- [ ] OSS regime supported
|
||||
- [ ] Reverse charge for B2B supported
|
||||
- [ ] Purchase orders can be created and received
|
||||
- [ ] Incoming stock shows in inventory
|
||||
- [ ] Customer export to CSV works
|
||||
|
||||
### Business Tier Ready When:
|
||||
- [ ] Multi-store dashboard works
|
||||
- [ ] Accounting export works
|
||||
- [ ] API access documented
|
||||
This document has moved to the orders module docs: [OMS Feature Plan](../modules/orders/oms-features.md)
|
||||
|
||||
@@ -1,288 +1 @@
|
||||
# Order Item Exception System
|
||||
|
||||
## Overview
|
||||
|
||||
The Order Item Exception system handles unmatched products during marketplace order imports. Instead of blocking imports when products cannot be found by GTIN, the system gracefully imports orders with placeholder products and creates exception records for QC resolution.
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Graceful Import** - Orders are imported even when products aren't found
|
||||
2. **Exception Tracking** - Unmatched items are tracked in `order_item_exceptions` table
|
||||
3. **Resolution Workflow** - Admin/store can assign correct products
|
||||
4. **Confirmation Blocking** - Orders with unresolved exceptions cannot be confirmed
|
||||
5. **Auto-Match** - Exceptions auto-resolve when matching products are imported
|
||||
|
||||
## Database Schema
|
||||
|
||||
### order_item_exceptions Table
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| id | Integer | Primary key |
|
||||
| order_item_id | Integer | FK to order_items (unique) |
|
||||
| store_id | Integer | FK to stores (indexed) |
|
||||
| original_gtin | String(50) | GTIN from marketplace |
|
||||
| original_product_name | String(500) | Product name from marketplace |
|
||||
| original_sku | String(100) | SKU from marketplace |
|
||||
| exception_type | String(50) | product_not_found, gtin_mismatch, duplicate_gtin |
|
||||
| status | String(50) | pending, resolved, ignored |
|
||||
| resolved_product_id | Integer | FK to products (nullable) |
|
||||
| resolved_at | DateTime | When resolved |
|
||||
| resolved_by | Integer | FK to users |
|
||||
| resolution_notes | Text | Optional notes |
|
||||
| created_at | DateTime | Created timestamp |
|
||||
| updated_at | DateTime | Updated timestamp |
|
||||
|
||||
### order_items Table (Modified)
|
||||
|
||||
Added column:
|
||||
- `needs_product_match: Boolean (default False, indexed)`
|
||||
|
||||
### Placeholder Product
|
||||
|
||||
Per-store placeholder with:
|
||||
- `gtin = "0000000000000"`
|
||||
- `gtin_type = "placeholder"`
|
||||
- `is_active = False`
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
Import Order from Marketplace
|
||||
│
|
||||
▼
|
||||
Query Products by GTIN
|
||||
│
|
||||
┌────┴────┐
|
||||
│ │
|
||||
Found Not Found
|
||||
│ │
|
||||
▼ ▼
|
||||
Normal Create with placeholder
|
||||
Item + Set needs_product_match=True
|
||||
+ Create OrderItemException
|
||||
│
|
||||
▼
|
||||
QC Dashboard shows pending
|
||||
│
|
||||
┌─────┴─────┐
|
||||
│ │
|
||||
Resolve Ignore
|
||||
(assign (with
|
||||
product) reason)
|
||||
│ │
|
||||
▼ ▼
|
||||
Update item Mark ignored
|
||||
product_id (still blocks)
|
||||
│
|
||||
▼
|
||||
Order can now be confirmed
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Admin Endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/v1/admin/order-exceptions` | List all exceptions |
|
||||
| GET | `/api/v1/admin/order-exceptions/stats` | Get exception statistics |
|
||||
| GET | `/api/v1/admin/order-exceptions/{id}` | Get exception details |
|
||||
| POST | `/api/v1/admin/order-exceptions/{id}/resolve` | Resolve with product |
|
||||
| POST | `/api/v1/admin/order-exceptions/{id}/ignore` | Mark as ignored |
|
||||
| POST | `/api/v1/admin/order-exceptions/bulk-resolve` | Bulk resolve by GTIN |
|
||||
|
||||
### Store Endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/v1/store/order-exceptions` | List store's exceptions |
|
||||
| GET | `/api/v1/store/order-exceptions/stats` | Get store's stats |
|
||||
| GET | `/api/v1/store/order-exceptions/{id}` | Get exception details |
|
||||
| POST | `/api/v1/store/order-exceptions/{id}/resolve` | Resolve with product |
|
||||
| POST | `/api/v1/store/order-exceptions/{id}/ignore` | Mark as ignored |
|
||||
| POST | `/api/v1/store/order-exceptions/bulk-resolve` | Bulk resolve by GTIN |
|
||||
|
||||
## Exception Types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `product_not_found` | GTIN not in store's product catalog |
|
||||
| `gtin_mismatch` | GTIN format issue |
|
||||
| `duplicate_gtin` | Multiple products with same GTIN |
|
||||
|
||||
## Exception Statuses
|
||||
|
||||
| Status | Description | Blocks Confirmation |
|
||||
|--------|-------------|---------------------|
|
||||
| `pending` | Awaiting resolution | Yes |
|
||||
| `resolved` | Product assigned | No |
|
||||
| `ignored` | Marked as ignored | Yes |
|
||||
|
||||
**Note:** Both `pending` and `ignored` statuses block order confirmation.
|
||||
|
||||
## Auto-Matching
|
||||
|
||||
When products are imported to the store catalog (via copy_to_store_catalog), the system automatically:
|
||||
|
||||
1. Collects GTINs of newly imported products
|
||||
2. Finds pending exceptions with matching GTINs
|
||||
3. Resolves them by assigning the new product
|
||||
|
||||
This happens automatically during:
|
||||
- Single product import
|
||||
- Bulk product import (marketplace sync)
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Order Creation (`app/services/order_service.py`)
|
||||
|
||||
The `create_letzshop_order()` method:
|
||||
1. Queries products by GTIN
|
||||
2. For missing GTINs, creates placeholder product
|
||||
3. Creates order items with `needs_product_match=True`
|
||||
4. Creates exception records
|
||||
|
||||
### Order Confirmation
|
||||
|
||||
Confirmation endpoints check for unresolved exceptions:
|
||||
- Admin: `app/api/v1/admin/letzshop.py`
|
||||
- Store: `app/api/v1/store/letzshop.py`
|
||||
|
||||
Raises `OrderHasUnresolvedExceptionsException` if exceptions exist.
|
||||
|
||||
### Product Import (`app/services/marketplace_product_service.py`)
|
||||
|
||||
The `copy_to_store_catalog()` method:
|
||||
1. Copies GTIN from MarketplaceProduct to Product
|
||||
2. Calls auto-match service after products are created
|
||||
3. Returns `auto_matched` count in response
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `models/database/order_item_exception.py` | Database model |
|
||||
| `models/schema/order_item_exception.py` | Pydantic schemas |
|
||||
| `app/services/order_item_exception_service.py` | Business logic |
|
||||
| `app/exceptions/order_item_exception.py` | Domain exceptions |
|
||||
| `app/api/v1/admin/order_item_exceptions.py` | Admin endpoints |
|
||||
| `app/api/v1/store/order_item_exceptions.py` | Store endpoints |
|
||||
| `alembic/versions/d2e3f4a5b6c7_add_order_item_exceptions.py` | Migration |
|
||||
|
||||
### Modified Files
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `models/database/order.py` | Added `needs_product_match`, exception relationship |
|
||||
| `models/database/__init__.py` | Export OrderItemException |
|
||||
| `models/schema/order.py` | Added exception info to OrderItemResponse |
|
||||
| `app/services/order_service.py` | Graceful handling of missing products |
|
||||
| `app/services/marketplace_product_service.py` | Auto-match on product import |
|
||||
| `app/api/v1/admin/letzshop.py` | Confirmation blocking check |
|
||||
| `app/api/v1/store/letzshop.py` | Confirmation blocking check |
|
||||
| `app/api/v1/admin/__init__.py` | Register exception router |
|
||||
| `app/api/v1/store/__init__.py` | Register exception router |
|
||||
| `app/exceptions/__init__.py` | Export new exceptions |
|
||||
|
||||
## Response Examples
|
||||
|
||||
### List Exceptions
|
||||
|
||||
```json
|
||||
{
|
||||
"exceptions": [
|
||||
{
|
||||
"id": 1,
|
||||
"order_item_id": 42,
|
||||
"store_id": 1,
|
||||
"original_gtin": "4006381333931",
|
||||
"original_product_name": "Funko Pop! Marvel...",
|
||||
"original_sku": "MH-FU-56757",
|
||||
"exception_type": "product_not_found",
|
||||
"status": "pending",
|
||||
"order_number": "LS-1-R702236251",
|
||||
"order_date": "2025-12-19T10:30:00Z",
|
||||
"created_at": "2025-12-19T11:00:00Z"
|
||||
}
|
||||
],
|
||||
"total": 15,
|
||||
"skip": 0,
|
||||
"limit": 50
|
||||
}
|
||||
```
|
||||
|
||||
### Exception Stats
|
||||
|
||||
```json
|
||||
{
|
||||
"pending": 15,
|
||||
"resolved": 42,
|
||||
"ignored": 3,
|
||||
"total": 60,
|
||||
"orders_with_exceptions": 8
|
||||
}
|
||||
```
|
||||
|
||||
### Resolve Exception
|
||||
|
||||
```json
|
||||
POST /api/v1/admin/order-exceptions/1/resolve
|
||||
{
|
||||
"product_id": 123,
|
||||
"notes": "Matched to correct product manually"
|
||||
}
|
||||
```
|
||||
|
||||
### Bulk Resolve
|
||||
|
||||
```json
|
||||
POST /api/v1/admin/order-exceptions/bulk-resolve?store_id=1
|
||||
{
|
||||
"gtin": "4006381333931",
|
||||
"product_id": 123,
|
||||
"notes": "New product imported"
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"resolved_count": 5,
|
||||
"gtin": "4006381333931",
|
||||
"product_id": 123
|
||||
}
|
||||
```
|
||||
|
||||
## Admin UI
|
||||
|
||||
The exceptions tab is available in the Letzshop management page:
|
||||
|
||||
**Location:** `/admin/marketplace/letzshop` → Exceptions tab
|
||||
|
||||
### Features
|
||||
|
||||
- **Stats Cards**: Shows pending, resolved, ignored, and affected orders counts
|
||||
- **Filters**: Search by GTIN/product name/order number, filter by status
|
||||
- **Exception Table**: Paginated list with product info, GTIN, order link, status
|
||||
- **Actions**:
|
||||
- **Resolve**: Opens modal with product search (autocomplete)
|
||||
- **Ignore**: Marks exception as ignored (still blocks confirmation)
|
||||
- **Bulk Resolve**: Checkbox to apply resolution to all exceptions with same GTIN
|
||||
|
||||
### Files
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `app/templates/admin/partials/letzshop-exceptions-tab.html` | Tab HTML template |
|
||||
| `app/templates/admin/marketplace-letzshop.html` | Main page (includes tab) |
|
||||
| `static/admin/js/marketplace-letzshop.js` | JavaScript handlers |
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Exception | HTTP Status | When |
|
||||
|-----------|-------------|------|
|
||||
| `OrderItemExceptionNotFoundException` | 404 | Exception not found |
|
||||
| `OrderHasUnresolvedExceptionsException` | 400 | Trying to confirm order with exceptions |
|
||||
| `ExceptionAlreadyResolvedException` | 400 | Trying to resolve already resolved exception |
|
||||
| `InvalidProductForExceptionException` | 400 | Invalid product (wrong store, inactive) |
|
||||
This document has moved to the orders module docs: [Order Item Exceptions](../modules/orders/exceptions.md)
|
||||
|
||||
@@ -1,371 +1 @@
|
||||
# Stock Management Integration
|
||||
|
||||
**Created:** January 1, 2026
|
||||
**Status:** Implemented
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the automatic inventory synchronization between orders and stock levels. When order status changes, inventory is automatically updated to maintain accurate stock counts.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Services Involved
|
||||
|
||||
```
|
||||
OrderService OrderInventoryService
|
||||
│ │
|
||||
├─ update_order_status() ──────────► handle_status_change()
|
||||
│ │
|
||||
│ ├─► reserve_for_order()
|
||||
│ ├─► fulfill_order()
|
||||
│ └─► release_order_reservation()
|
||||
│ │
|
||||
│ ▼
|
||||
│ InventoryService
|
||||
│ │
|
||||
│ ├─► reserve_inventory()
|
||||
│ ├─► fulfill_reservation()
|
||||
│ └─► release_reservation()
|
||||
```
|
||||
|
||||
### Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `app/services/order_inventory_service.py` | Orchestrates order-inventory operations |
|
||||
| `app/services/order_service.py` | Calls inventory hooks on status change |
|
||||
| `app/services/inventory_service.py` | Low-level inventory operations |
|
||||
|
||||
## Status Change Inventory Actions
|
||||
|
||||
| Status Transition | Inventory Action | Description |
|
||||
|-------------------|------------------|-------------|
|
||||
| Any → `processing` | Reserve | Reserves stock for order items |
|
||||
| Any → `shipped` | Fulfill | Deducts from stock and releases reservation |
|
||||
| Any → `cancelled` | Release | Returns reserved stock to available |
|
||||
|
||||
## Inventory Operations
|
||||
|
||||
### Reserve Inventory
|
||||
|
||||
When an order status changes to `processing`:
|
||||
|
||||
1. For each order item:
|
||||
- Find inventory record with available quantity
|
||||
- Increase `reserved_quantity` by item quantity
|
||||
- Log the reservation
|
||||
|
||||
2. Placeholder products (unmatched Letzshop items) are skipped
|
||||
|
||||
### Fulfill Inventory
|
||||
|
||||
When an order status changes to `shipped`:
|
||||
|
||||
1. For each order item:
|
||||
- Decrease `quantity` by item quantity (stock consumed)
|
||||
- Decrease `reserved_quantity` accordingly
|
||||
- Log the fulfillment
|
||||
|
||||
### Release Reservation
|
||||
|
||||
When an order is `cancelled`:
|
||||
|
||||
1. For each order item:
|
||||
- Decrease `reserved_quantity` (stock becomes available again)
|
||||
- Total `quantity` remains unchanged
|
||||
- Log the release
|
||||
|
||||
## Error Handling
|
||||
|
||||
Inventory operations use **soft failure** - if inventory cannot be updated:
|
||||
|
||||
1. Warning is logged
|
||||
2. Order status update continues
|
||||
3. Inventory can be manually adjusted
|
||||
|
||||
This ensures orders are never blocked by inventory issues while providing visibility into any problems.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
### Placeholder Products
|
||||
|
||||
Letzshop orders may contain unmatched GTINs that map to placeholder products. These are identified by:
|
||||
- GTIN `0000000000000`
|
||||
- Product linked to placeholder MarketplaceProduct
|
||||
|
||||
Inventory operations skip placeholder products since they have no real stock.
|
||||
|
||||
### Missing Inventory
|
||||
|
||||
If a product has no inventory record:
|
||||
- Operation is skipped with `skip_missing=True`
|
||||
- Item is logged in `skipped_items` list
|
||||
- No error is raised
|
||||
|
||||
### Multi-Location Inventory
|
||||
|
||||
The service finds the first location with available stock:
|
||||
```python
|
||||
def _find_inventory_location(db, product_id, store_id):
|
||||
return (
|
||||
db.query(Inventory)
|
||||
.filter(
|
||||
Inventory.product_id == product_id,
|
||||
Inventory.store_id == store_id,
|
||||
Inventory.quantity > Inventory.reserved_quantity,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
```
|
||||
|
||||
## Usage Example
|
||||
|
||||
### Automatic (Via Order Status Update)
|
||||
|
||||
```python
|
||||
from app.services.order_service import order_service
|
||||
from models.schema.order import OrderUpdate
|
||||
|
||||
# Update order status - inventory is handled automatically
|
||||
order = order_service.update_order_status(
|
||||
db=db,
|
||||
store_id=store_id,
|
||||
order_id=order_id,
|
||||
order_update=OrderUpdate(status="processing")
|
||||
)
|
||||
# Inventory is now reserved for this order
|
||||
```
|
||||
|
||||
### Direct (Manual Operations)
|
||||
|
||||
```python
|
||||
from app.services.order_inventory_service import order_inventory_service
|
||||
|
||||
# Reserve inventory for an order
|
||||
result = order_inventory_service.reserve_for_order(
|
||||
db=db,
|
||||
store_id=store_id,
|
||||
order_id=order_id,
|
||||
skip_missing=True
|
||||
)
|
||||
print(f"Reserved: {result['reserved_count']}, Skipped: {len(result['skipped_items'])}")
|
||||
|
||||
# Fulfill when shipped
|
||||
result = order_inventory_service.fulfill_order(
|
||||
db=db,
|
||||
store_id=store_id,
|
||||
order_id=order_id
|
||||
)
|
||||
|
||||
# Release if cancelled
|
||||
result = order_inventory_service.release_order_reservation(
|
||||
db=db,
|
||||
store_id=store_id,
|
||||
order_id=order_id
|
||||
)
|
||||
```
|
||||
|
||||
## Inventory Model
|
||||
|
||||
```python
|
||||
class Inventory:
|
||||
quantity: int # Total stock
|
||||
reserved_quantity: int # Reserved for pending orders
|
||||
|
||||
@property
|
||||
def available_quantity(self):
|
||||
return self.quantity - self.reserved_quantity
|
||||
```
|
||||
|
||||
## Logging
|
||||
|
||||
All inventory operations are logged:
|
||||
|
||||
```
|
||||
INFO: Reserved 2 units of product 123 for order ORD-1-20260101-ABC123
|
||||
INFO: Order ORD-1-20260101-ABC123: reserved 3 items, skipped 1
|
||||
INFO: Fulfilled 2 units of product 123 for order ORD-1-20260101-ABC123
|
||||
WARNING: Order ORD-1-20260101-ABC123 inventory operation failed: No inventory found
|
||||
```
|
||||
|
||||
## Audit Trail (Phase 2)
|
||||
|
||||
All inventory operations are logged to the `inventory_transactions` table.
|
||||
|
||||
### Transaction Types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `reserve` | Stock reserved for order |
|
||||
| `fulfill` | Reserved stock consumed (shipped) |
|
||||
| `release` | Reserved stock released (cancelled) |
|
||||
| `adjust` | Manual adjustment (+/-) |
|
||||
| `set` | Set to exact quantity |
|
||||
| `import` | Initial import/sync |
|
||||
| `return` | Stock returned from customer |
|
||||
|
||||
### Transaction Record
|
||||
|
||||
```python
|
||||
class InventoryTransaction:
|
||||
id: int
|
||||
store_id: int
|
||||
product_id: int
|
||||
inventory_id: int | None
|
||||
transaction_type: TransactionType
|
||||
quantity_change: int # Positive = add, negative = remove
|
||||
quantity_after: int # Snapshot after transaction
|
||||
reserved_after: int # Snapshot after transaction
|
||||
location: str | None
|
||||
warehouse: str | None
|
||||
order_id: int | None # Link to order if applicable
|
||||
order_number: str | None
|
||||
reason: str | None # Human-readable reason
|
||||
created_by: str | None # User/system identifier
|
||||
created_at: datetime
|
||||
```
|
||||
|
||||
### Example Transaction Query
|
||||
|
||||
```python
|
||||
from models.database import InventoryTransaction, TransactionType
|
||||
|
||||
# Get all transactions for an order
|
||||
transactions = db.query(InventoryTransaction).filter(
|
||||
InventoryTransaction.order_id == order_id
|
||||
).order_by(InventoryTransaction.created_at).all()
|
||||
|
||||
# Get recent stock changes for a product
|
||||
recent = db.query(InventoryTransaction).filter(
|
||||
InventoryTransaction.product_id == product_id,
|
||||
InventoryTransaction.store_id == store_id,
|
||||
).order_by(InventoryTransaction.created_at.desc()).limit(10).all()
|
||||
```
|
||||
|
||||
## Partial Shipments (Phase 3)
|
||||
|
||||
Orders can be partially shipped, allowing stores to ship items as they become available.
|
||||
|
||||
### Status Flow
|
||||
|
||||
```
|
||||
pending → processing → partially_shipped → shipped → delivered
|
||||
↘ ↗
|
||||
→ shipped (if all items shipped at once)
|
||||
```
|
||||
|
||||
### OrderItem Tracking
|
||||
|
||||
Each order item has a `shipped_quantity` field:
|
||||
|
||||
```python
|
||||
class OrderItem:
|
||||
quantity: int # Total ordered
|
||||
shipped_quantity: int # Units shipped so far
|
||||
|
||||
@property
|
||||
def remaining_quantity(self):
|
||||
return self.quantity - self.shipped_quantity
|
||||
|
||||
@property
|
||||
def is_fully_shipped(self):
|
||||
return self.shipped_quantity >= self.quantity
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
#### Get Shipment Status
|
||||
|
||||
```http
|
||||
GET /api/v1/store/orders/{order_id}/shipment-status
|
||||
```
|
||||
|
||||
Returns item-level shipment status:
|
||||
```json
|
||||
{
|
||||
"order_id": 123,
|
||||
"order_number": "ORD-1-20260101-ABC123",
|
||||
"order_status": "partially_shipped",
|
||||
"is_fully_shipped": false,
|
||||
"is_partially_shipped": true,
|
||||
"shipped_item_count": 1,
|
||||
"total_item_count": 3,
|
||||
"total_shipped_units": 2,
|
||||
"total_ordered_units": 5,
|
||||
"items": [
|
||||
{
|
||||
"item_id": 1,
|
||||
"product_name": "Widget A",
|
||||
"quantity": 2,
|
||||
"shipped_quantity": 2,
|
||||
"remaining_quantity": 0,
|
||||
"is_fully_shipped": true
|
||||
},
|
||||
{
|
||||
"item_id": 2,
|
||||
"product_name": "Widget B",
|
||||
"quantity": 3,
|
||||
"shipped_quantity": 0,
|
||||
"remaining_quantity": 3,
|
||||
"is_fully_shipped": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Ship Individual Item
|
||||
|
||||
```http
|
||||
POST /api/v1/store/orders/{order_id}/items/{item_id}/ship
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"quantity": 2 // Optional - defaults to remaining quantity
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"order_id": 123,
|
||||
"item_id": 1,
|
||||
"fulfilled_quantity": 2,
|
||||
"shipped_quantity": 2,
|
||||
"remaining_quantity": 0,
|
||||
"is_fully_shipped": true
|
||||
}
|
||||
```
|
||||
|
||||
### Automatic Status Updates
|
||||
|
||||
When shipping items:
|
||||
1. If some items are shipped → status becomes `partially_shipped`
|
||||
2. If all items are fully shipped → status becomes `shipped`
|
||||
|
||||
### Service Usage
|
||||
|
||||
```python
|
||||
from app.services.order_inventory_service import order_inventory_service
|
||||
|
||||
# Ship partial quantity of an item
|
||||
result = order_inventory_service.fulfill_item(
|
||||
db=db,
|
||||
store_id=store_id,
|
||||
order_id=order_id,
|
||||
item_id=item_id,
|
||||
quantity=2, # Ship 2 units
|
||||
)
|
||||
|
||||
# Get shipment status
|
||||
status = order_inventory_service.get_shipment_status(
|
||||
db=db,
|
||||
store_id=store_id,
|
||||
order_id=order_id,
|
||||
)
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Multi-Location Selection** - Choose which location to draw from
|
||||
2. **Backorder Support** - Handle orders when stock is insufficient
|
||||
3. **Return Processing** - Increase stock when orders are returned
|
||||
This document has moved to the orders module docs: [Stock Integration](../modules/orders/stock-integration.md)
|
||||
|
||||
@@ -1,454 +1,3 @@
|
||||
# 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?
|
||||
This document has moved to the billing module docs: [Subscription Workflow](../modules/billing/subscription-workflow.md)
|
||||
|
||||
@@ -1,275 +1 @@
|
||||
# Unified Order Schema Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the unified order schema that consolidates all order types (direct and marketplace) into a single `orders` table with snapshotted customer and address data.
|
||||
|
||||
## Design Decision: Option B - Single Unified Table
|
||||
|
||||
After analyzing the gap between internal orders and Letzshop orders, we chose **Option B: Full Import to Order Table** with the following key principles:
|
||||
|
||||
1. **Single `orders` table** for all channels (direct, letzshop, future marketplaces)
|
||||
2. **Customer/address snapshots** preserved at order time (not just FK references)
|
||||
3. **Products must exist** in catalog - GTIN lookup errors trigger investigation
|
||||
4. **Inactive customers** created for marketplace imports until they register on storefront
|
||||
5. **No separate `letzshop_orders` table** - eliminates sync issues
|
||||
|
||||
## Schema Design
|
||||
|
||||
### Order Table
|
||||
|
||||
The `orders` table now includes:
|
||||
|
||||
```
|
||||
orders
|
||||
├── Identity
|
||||
│ ├── id (PK)
|
||||
│ ├── store_id (FK → stores)
|
||||
│ ├── customer_id (FK → customers)
|
||||
│ └── order_number (unique)
|
||||
│
|
||||
├── Channel/Source
|
||||
│ ├── channel (direct | letzshop)
|
||||
│ ├── external_order_id
|
||||
│ ├── external_shipment_id
|
||||
│ ├── external_order_number
|
||||
│ └── external_data (JSON - raw marketplace data)
|
||||
│
|
||||
├── Status
|
||||
│ └── status (pending | processing | shipped | delivered | cancelled | refunded)
|
||||
│
|
||||
├── Financials
|
||||
│ ├── subtotal (nullable for marketplace)
|
||||
│ ├── tax_amount
|
||||
│ ├── shipping_amount
|
||||
│ ├── discount_amount
|
||||
│ ├── total_amount
|
||||
│ └── currency
|
||||
│
|
||||
├── Customer Snapshot
|
||||
│ ├── customer_first_name
|
||||
│ ├── customer_last_name
|
||||
│ ├── customer_email
|
||||
│ ├── customer_phone
|
||||
│ └── customer_locale
|
||||
│
|
||||
├── Shipping Address Snapshot
|
||||
│ ├── ship_first_name
|
||||
│ ├── ship_last_name
|
||||
│ ├── ship_company
|
||||
│ ├── ship_address_line_1
|
||||
│ ├── ship_address_line_2
|
||||
│ ├── ship_city
|
||||
│ ├── ship_postal_code
|
||||
│ └── ship_country_iso
|
||||
│
|
||||
├── Billing Address Snapshot
|
||||
│ ├── bill_first_name
|
||||
│ ├── bill_last_name
|
||||
│ ├── bill_company
|
||||
│ ├── bill_address_line_1
|
||||
│ ├── bill_address_line_2
|
||||
│ ├── bill_city
|
||||
│ ├── bill_postal_code
|
||||
│ └── bill_country_iso
|
||||
│
|
||||
├── Tracking
|
||||
│ ├── shipping_method
|
||||
│ ├── tracking_number
|
||||
│ └── tracking_provider
|
||||
│
|
||||
├── Notes
|
||||
│ ├── customer_notes
|
||||
│ └── internal_notes
|
||||
│
|
||||
└── Timestamps
|
||||
├── order_date (when customer placed order)
|
||||
├── confirmed_at
|
||||
├── shipped_at
|
||||
├── delivered_at
|
||||
├── cancelled_at
|
||||
├── created_at
|
||||
└── updated_at
|
||||
```
|
||||
|
||||
### OrderItem Table
|
||||
|
||||
The `order_items` table includes:
|
||||
|
||||
```
|
||||
order_items
|
||||
├── Identity
|
||||
│ ├── id (PK)
|
||||
│ ├── order_id (FK → orders)
|
||||
│ └── product_id (FK → products, NOT NULL)
|
||||
│
|
||||
├── Product Snapshot
|
||||
│ ├── product_name
|
||||
│ ├── product_sku
|
||||
│ ├── gtin
|
||||
│ └── gtin_type (ean13, upc, isbn, etc.)
|
||||
│
|
||||
├── Pricing
|
||||
│ ├── quantity
|
||||
│ ├── unit_price
|
||||
│ └── total_price
|
||||
│
|
||||
├── External References
|
||||
│ ├── external_item_id (Letzshop inventory unit ID)
|
||||
│ └── external_variant_id
|
||||
│
|
||||
├── Item State (marketplace confirmation)
|
||||
│ └── item_state (confirmed_available | confirmed_unavailable)
|
||||
│
|
||||
└── Inventory
|
||||
├── inventory_reserved
|
||||
└── inventory_fulfilled
|
||||
```
|
||||
|
||||
## Status Mapping
|
||||
|
||||
| Letzshop State | Order Status | Description |
|
||||
|----------------|--------------|-------------|
|
||||
| `unconfirmed` | `pending` | Order received, awaiting confirmation |
|
||||
| `confirmed` | `processing` | Items confirmed, being prepared |
|
||||
| `confirmed` + tracking | `shipped` | Shipped with tracking info |
|
||||
| `declined` | `cancelled` | All items declined |
|
||||
|
||||
## Customer Handling
|
||||
|
||||
When importing marketplace orders:
|
||||
|
||||
1. Look up customer by `(store_id, email)`
|
||||
2. If not found, create with `is_active=False`
|
||||
3. Customer becomes active when they register on storefront
|
||||
4. Customer info is always snapshotted in order (regardless of customer record)
|
||||
|
||||
This ensures:
|
||||
- Customer history is preserved even if customer info changes
|
||||
- Marketplace customers can later claim their order history
|
||||
- No data loss if customer record is modified
|
||||
|
||||
## Shipping Workflows
|
||||
|
||||
### Scenario 1: Letzshop Auto-Shipping
|
||||
|
||||
When using Letzshop's shipping service:
|
||||
|
||||
1. Order confirmed → `status = processing`
|
||||
2. Letzshop auto-creates shipment with their carrier
|
||||
3. Operator picks & packs
|
||||
4. Operator clicks "Retrieve Shipping Info"
|
||||
5. App fetches tracking from Letzshop API
|
||||
6. Order updated → `status = shipped`
|
||||
|
||||
### Scenario 2: Store Own Shipping
|
||||
|
||||
When store uses their own carrier:
|
||||
|
||||
1. Order confirmed → `status = processing`
|
||||
2. Operator picks & packs with own carrier
|
||||
3. Operator enters tracking info in app
|
||||
4. App sends tracking to Letzshop API
|
||||
5. Order updated → `status = shipped`
|
||||
|
||||
## Removed: LetzshopOrder Table
|
||||
|
||||
The `letzshop_orders` table has been removed. All data now goes directly into the unified `orders` table with `channel = 'letzshop'`.
|
||||
|
||||
### Migration of Existing References
|
||||
|
||||
- `LetzshopFulfillmentQueue.letzshop_order_id` → `order_id` (FK to `orders`)
|
||||
- `LetzshopSyncLog` - unchanged (no order reference)
|
||||
- `LetzshopHistoricalImportJob` - unchanged (no order reference)
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `models/database/order.py` | Complete rewrite with snapshots |
|
||||
| `models/database/letzshop.py` | Removed `LetzshopOrder`, updated `LetzshopFulfillmentQueue` |
|
||||
| `models/schema/order.py` | Updated schemas for new structure |
|
||||
| `models/schema/letzshop.py` | Updated schemas for unified Order model |
|
||||
| `app/services/order_service.py` | Unified service with `create_letzshop_order()` |
|
||||
| `app/services/letzshop/order_service.py` | Updated to use unified Order model |
|
||||
| `app/api/v1/admin/letzshop.py` | Updated endpoints for unified model |
|
||||
| `alembic/versions/c1d2e3f4a5b6_unified_order_schema.py` | Migration |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
All Letzshop order endpoints now use the unified Order model:
|
||||
|
||||
| Endpoint | Description |
|
||||
|----------|-------------|
|
||||
| `GET /admin/letzshop/stores/{id}/orders` | List orders with `channel='letzshop'` filter |
|
||||
| `GET /admin/letzshop/orders/{id}` | Get order detail with items |
|
||||
| `POST /admin/letzshop/stores/{id}/orders/{id}/confirm` | Confirm items via `external_item_id` |
|
||||
| `POST /admin/letzshop/stores/{id}/orders/{id}/reject` | Decline items via `external_item_id` |
|
||||
| `POST /admin/letzshop/stores/{id}/orders/{id}/items/{item_id}/confirm` | Confirm single item |
|
||||
| `POST /admin/letzshop/stores/{id}/orders/{id}/items/{item_id}/decline` | Decline single item |
|
||||
|
||||
## Order Number Format
|
||||
|
||||
| Channel | Format | Example |
|
||||
|---------|--------|---------|
|
||||
| Direct | `ORD-{store_id}-{date}-{random}` | `ORD-1-20251219-A1B2C3` |
|
||||
| Letzshop | `LS-{store_id}-{letzshop_order_number}` | `LS-1-ORD-123456` |
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Product Not Found by GTIN
|
||||
|
||||
When importing a Letzshop order, if a product cannot be found by its GTIN:
|
||||
|
||||
```python
|
||||
raise ValidationException(
|
||||
f"Product not found for GTIN {gtin}. "
|
||||
f"Please ensure the product catalog is in sync."
|
||||
)
|
||||
```
|
||||
|
||||
This is intentional - the Letzshop catalog is sourced from the store catalog, so missing products indicate a sync issue that must be investigated.
|
||||
|
||||
## Future Considerations
|
||||
|
||||
### Performance at Scale
|
||||
|
||||
As the orders table grows, consider:
|
||||
|
||||
1. **Partitioning** by `order_date` or `store_id`
|
||||
2. **Archiving** old orders to separate tables
|
||||
3. **Read replicas** for reporting queries
|
||||
4. **Materialized views** for dashboard statistics
|
||||
|
||||
### Additional Marketplaces
|
||||
|
||||
The schema supports additional channels:
|
||||
|
||||
```python
|
||||
channel = Column(String(50)) # direct, letzshop, amazon, ebay, etc.
|
||||
```
|
||||
|
||||
Each marketplace would use:
|
||||
- `external_order_id` - Marketplace order ID
|
||||
- `external_shipment_id` - Marketplace shipment ID
|
||||
- `external_order_number` - Display order number
|
||||
- `external_data` - Raw marketplace data (JSON)
|
||||
|
||||
## Implementation Status
|
||||
|
||||
- [x] Order model with snapshots
|
||||
- [x] OrderItem model with GTIN fields
|
||||
- [x] LetzshopFulfillmentQueue updated
|
||||
- [x] LetzshopOrder removed
|
||||
- [x] Database migration created
|
||||
- [x] Order schemas updated
|
||||
- [x] Unified order service created
|
||||
- [x] Letzshop order service updated
|
||||
- [x] Letzshop schemas updated
|
||||
- [x] API endpoints updated
|
||||
- [x] Frontend updated
|
||||
- [x] Orders tab template (status badges, filters, table)
|
||||
- [x] Order detail page (snapshots, items, tracking)
|
||||
- [x] JavaScript (API params, response handling)
|
||||
- [x] Tracking modal (tracking_provider field)
|
||||
- [x] Order items modal (items array, item_state)
|
||||
This document has moved to the orders module docs: [Unified Order View](../modules/orders/unified-order-view.md)
|
||||
|
||||
@@ -1,734 +1 @@
|
||||
# VAT Invoice Feature - Technical Specification
|
||||
|
||||
## Overview
|
||||
|
||||
Generate compliant PDF invoices with correct VAT calculation based on destination country, handling EU cross-border VAT rules including OSS (One-Stop-Shop) regime.
|
||||
|
||||
---
|
||||
|
||||
## EU VAT Rules Summary
|
||||
|
||||
### Standard VAT Rates by Country (2024)
|
||||
|
||||
| Country | Code | Standard Rate | Reduced |
|
||||
|---------|------|---------------|---------|
|
||||
| Luxembourg | LU | 17% | 8%, 3% |
|
||||
| Germany | DE | 19% | 7% |
|
||||
| France | FR | 20% | 10%, 5.5% |
|
||||
| Belgium | BE | 21% | 12%, 6% |
|
||||
| Netherlands | NL | 21% | 9% |
|
||||
| Austria | AT | 20% | 13%, 10% |
|
||||
| Italy | IT | 22% | 10%, 5%, 4% |
|
||||
| Spain | ES | 21% | 10%, 4% |
|
||||
| Portugal | PT | 23% | 13%, 6% |
|
||||
| Ireland | IE | 23% | 13.5%, 9% |
|
||||
| Poland | PL | 23% | 8%, 5% |
|
||||
| Czech Republic | CZ | 21% | 15%, 10% |
|
||||
| ... | ... | ... | ... |
|
||||
|
||||
### When to Apply Which VAT
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ VAT DECISION TREE │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Is buyer a business with valid VAT number? │
|
||||
│ ├── YES → Reverse charge (0% VAT, buyer accounts for it) │
|
||||
│ └── NO → Continue... │
|
||||
│ │
|
||||
│ Is destination same country as seller? │
|
||||
│ ├── YES → Apply domestic VAT (Luxembourg = 17%) │
|
||||
│ └── NO → Continue... │
|
||||
│ │
|
||||
│ Is seller registered for OSS? │
|
||||
│ ├── YES → Apply destination country VAT rate │
|
||||
│ └── NO → Continue... │
|
||||
│ │
|
||||
│ Has seller exceeded €10,000 EU threshold? │
|
||||
│ ├── YES → Must register OSS, apply destination VAT │
|
||||
│ └── NO → Apply origin country VAT (Luxembourg = 17%) │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
### New Tables
|
||||
|
||||
```sql
|
||||
-- VAT configuration per store
|
||||
CREATE TABLE store_vat_settings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
store_id UUID NOT NULL REFERENCES stores(id),
|
||||
|
||||
-- Merchant details for invoices
|
||||
merchant_name VARCHAR(255) NOT NULL,
|
||||
merchant_address TEXT NOT NULL,
|
||||
merchant_city VARCHAR(100) NOT NULL,
|
||||
merchant_postal_code VARCHAR(20) NOT NULL,
|
||||
merchant_country VARCHAR(2) NOT NULL DEFAULT 'LU',
|
||||
vat_number VARCHAR(50), -- e.g., "LU12345678"
|
||||
|
||||
-- VAT regime
|
||||
is_vat_registered BOOLEAN DEFAULT TRUE,
|
||||
is_oss_registered BOOLEAN DEFAULT FALSE, -- One-Stop-Shop
|
||||
|
||||
-- Invoice numbering
|
||||
invoice_prefix VARCHAR(20) DEFAULT 'INV',
|
||||
invoice_next_number INTEGER DEFAULT 1,
|
||||
|
||||
-- Optional
|
||||
payment_terms TEXT, -- e.g., "Due upon receipt"
|
||||
bank_details TEXT, -- IBAN, etc.
|
||||
footer_text TEXT, -- Legal text, thank you message
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- EU VAT rates reference table
|
||||
CREATE TABLE eu_vat_rates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
country_code VARCHAR(2) NOT NULL, -- ISO 3166-1 alpha-2
|
||||
country_name VARCHAR(100) NOT NULL,
|
||||
standard_rate DECIMAL(5,2) NOT NULL, -- e.g., 17.00
|
||||
reduced_rate_1 DECIMAL(5,2),
|
||||
reduced_rate_2 DECIMAL(5,2),
|
||||
super_reduced_rate DECIMAL(5,2),
|
||||
effective_from DATE NOT NULL,
|
||||
effective_until DATE, -- NULL = current
|
||||
|
||||
UNIQUE(country_code, effective_from)
|
||||
);
|
||||
|
||||
-- Generated invoices
|
||||
CREATE TABLE invoices (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
store_id UUID NOT NULL REFERENCES stores(id),
|
||||
order_id UUID REFERENCES orders(id), -- Can be NULL for manual invoices
|
||||
|
||||
-- Invoice identity
|
||||
invoice_number VARCHAR(50) NOT NULL, -- e.g., "INV-2024-0042"
|
||||
invoice_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
|
||||
-- Parties
|
||||
seller_details JSONB NOT NULL, -- Snapshot of store at invoice time
|
||||
buyer_details JSONB NOT NULL, -- Snapshot of customer at invoice time
|
||||
|
||||
-- VAT calculation details
|
||||
destination_country VARCHAR(2) NOT NULL,
|
||||
vat_regime VARCHAR(20) NOT NULL, -- 'domestic', 'oss', 'reverse_charge', 'origin'
|
||||
vat_rate DECIMAL(5,2) NOT NULL,
|
||||
|
||||
-- Amounts
|
||||
subtotal_net DECIMAL(12,2) NOT NULL, -- Before VAT
|
||||
vat_amount DECIMAL(12,2) NOT NULL,
|
||||
total_gross DECIMAL(12,2) NOT NULL, -- After VAT
|
||||
currency VARCHAR(3) DEFAULT 'EUR',
|
||||
|
||||
-- Line items snapshot
|
||||
line_items JSONB NOT NULL,
|
||||
|
||||
-- PDF storage
|
||||
pdf_path VARCHAR(500), -- Path to generated PDF
|
||||
pdf_generated_at TIMESTAMP,
|
||||
|
||||
-- Status
|
||||
status VARCHAR(20) DEFAULT 'draft', -- draft, issued, paid, cancelled
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
UNIQUE(store_id, invoice_number)
|
||||
);
|
||||
```
|
||||
|
||||
### Line Items JSONB Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"description": "Product Name",
|
||||
"sku": "ABC123",
|
||||
"quantity": 2,
|
||||
"unit_price_net": 25.00,
|
||||
"vat_rate": 17.00,
|
||||
"vat_amount": 8.50,
|
||||
"line_total_net": 50.00,
|
||||
"line_total_gross": 58.50
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Service Layer
|
||||
|
||||
### VATService
|
||||
|
||||
```python
|
||||
# app/services/vat_service.py
|
||||
|
||||
from decimal import Decimal
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
class VATService:
|
||||
"""Handles VAT calculation logic for EU cross-border sales."""
|
||||
|
||||
# Fallback rates if DB lookup fails
|
||||
DEFAULT_RATES = {
|
||||
'LU': Decimal('17.00'),
|
||||
'DE': Decimal('19.00'),
|
||||
'FR': Decimal('20.00'),
|
||||
'BE': Decimal('21.00'),
|
||||
'NL': Decimal('21.00'),
|
||||
'AT': Decimal('20.00'),
|
||||
'IT': Decimal('22.00'),
|
||||
'ES': Decimal('21.00'),
|
||||
# ... etc
|
||||
}
|
||||
|
||||
def __init__(self, db_session):
|
||||
self.db = db_session
|
||||
|
||||
def get_vat_rate(self, country_code: str, as_of: date = None) -> Decimal:
|
||||
"""Get current VAT rate for a country."""
|
||||
as_of = as_of or date.today()
|
||||
|
||||
# Try DB first
|
||||
rate = self.db.query(EUVATRate).filter(
|
||||
EUVATRate.country_code == country_code,
|
||||
EUVATRate.effective_from <= as_of,
|
||||
(EUVATRate.effective_until.is_(None) | (EUVATRate.effective_until >= as_of))
|
||||
).first()
|
||||
|
||||
if rate:
|
||||
return rate.standard_rate
|
||||
|
||||
# Fallback
|
||||
return self.DEFAULT_RATES.get(country_code, Decimal('0.00'))
|
||||
|
||||
def determine_vat_regime(
|
||||
self,
|
||||
seller_country: str,
|
||||
buyer_country: str,
|
||||
buyer_vat_number: Optional[str],
|
||||
seller_is_oss: bool,
|
||||
seller_exceeded_threshold: bool = False
|
||||
) -> tuple[str, Decimal]:
|
||||
"""
|
||||
Determine which VAT regime applies and the rate.
|
||||
|
||||
Returns: (regime_name, vat_rate)
|
||||
"""
|
||||
# B2B with valid VAT number = reverse charge
|
||||
if buyer_vat_number and self._validate_vat_number(buyer_vat_number):
|
||||
return ('reverse_charge', Decimal('0.00'))
|
||||
|
||||
# Domestic sale
|
||||
if seller_country == buyer_country:
|
||||
return ('domestic', self.get_vat_rate(seller_country))
|
||||
|
||||
# Cross-border B2C
|
||||
if seller_is_oss:
|
||||
# OSS: destination country VAT
|
||||
return ('oss', self.get_vat_rate(buyer_country))
|
||||
|
||||
if seller_exceeded_threshold:
|
||||
# Should be OSS but isn't - use destination anyway (compliance issue)
|
||||
return ('oss_required', self.get_vat_rate(buyer_country))
|
||||
|
||||
# Under threshold: origin country VAT
|
||||
return ('origin', self.get_vat_rate(seller_country))
|
||||
|
||||
def _validate_vat_number(self, vat_number: str) -> bool:
|
||||
"""
|
||||
Validate EU VAT number format.
|
||||
For production: integrate with VIES API.
|
||||
"""
|
||||
if not vat_number or len(vat_number) < 4:
|
||||
return False
|
||||
|
||||
# Basic format check: 2-letter country + numbers
|
||||
country = vat_number[:2].upper()
|
||||
return country in self.DEFAULT_RATES
|
||||
|
||||
def calculate_invoice_totals(
|
||||
self,
|
||||
line_items: list[dict],
|
||||
vat_rate: Decimal
|
||||
) -> dict:
|
||||
"""Calculate invoice totals with VAT."""
|
||||
subtotal_net = Decimal('0.00')
|
||||
|
||||
for item in line_items:
|
||||
quantity = Decimal(str(item['quantity']))
|
||||
unit_price = Decimal(str(item['unit_price_net']))
|
||||
line_net = quantity * unit_price
|
||||
|
||||
item['line_total_net'] = float(line_net)
|
||||
item['vat_rate'] = float(vat_rate)
|
||||
item['vat_amount'] = float(line_net * vat_rate / 100)
|
||||
item['line_total_gross'] = float(line_net + line_net * vat_rate / 100)
|
||||
|
||||
subtotal_net += line_net
|
||||
|
||||
vat_amount = subtotal_net * vat_rate / 100
|
||||
total_gross = subtotal_net + vat_amount
|
||||
|
||||
return {
|
||||
'subtotal_net': float(subtotal_net),
|
||||
'vat_rate': float(vat_rate),
|
||||
'vat_amount': float(vat_amount),
|
||||
'total_gross': float(total_gross),
|
||||
'line_items': line_items
|
||||
}
|
||||
```
|
||||
|
||||
### InvoiceService
|
||||
|
||||
```python
|
||||
# app/services/invoice_service.py
|
||||
|
||||
class InvoiceService:
|
||||
"""Generate and manage invoices."""
|
||||
|
||||
def __init__(self, db_session, vat_service: VATService):
|
||||
self.db = db_session
|
||||
self.vat = vat_service
|
||||
|
||||
def create_invoice_from_order(
|
||||
self,
|
||||
order_id: UUID,
|
||||
store_id: UUID
|
||||
) -> Invoice:
|
||||
"""Generate invoice from an existing order."""
|
||||
order = self.db.query(Order).get(order_id)
|
||||
vat_settings = self.db.query(StoreVATSettings).filter_by(
|
||||
store_id=store_id
|
||||
).first()
|
||||
|
||||
if not vat_settings:
|
||||
raise ValueError("Store VAT settings not configured")
|
||||
|
||||
# Determine VAT regime
|
||||
regime, rate = self.vat.determine_vat_regime(
|
||||
seller_country=vat_settings.merchant_country,
|
||||
buyer_country=order.shipping_country,
|
||||
buyer_vat_number=order.customer_vat_number,
|
||||
seller_is_oss=vat_settings.is_oss_registered
|
||||
)
|
||||
|
||||
# Prepare line items
|
||||
line_items = [
|
||||
{
|
||||
'description': item.product_name,
|
||||
'sku': item.sku,
|
||||
'quantity': item.quantity,
|
||||
'unit_price_net': float(item.unit_price)
|
||||
}
|
||||
for item in order.items
|
||||
]
|
||||
|
||||
# Calculate totals
|
||||
totals = self.vat.calculate_invoice_totals(line_items, rate)
|
||||
|
||||
# Generate invoice number
|
||||
invoice_number = self._generate_invoice_number(vat_settings)
|
||||
|
||||
# Create invoice
|
||||
invoice = Invoice(
|
||||
store_id=store_id,
|
||||
order_id=order_id,
|
||||
invoice_number=invoice_number,
|
||||
invoice_date=date.today(),
|
||||
seller_details=self._snapshot_seller(vat_settings),
|
||||
buyer_details=self._snapshot_buyer(order),
|
||||
destination_country=order.shipping_country,
|
||||
vat_regime=regime,
|
||||
vat_rate=rate,
|
||||
subtotal_net=totals['subtotal_net'],
|
||||
vat_amount=totals['vat_amount'],
|
||||
total_gross=totals['total_gross'],
|
||||
line_items={'items': totals['line_items']},
|
||||
status='issued'
|
||||
)
|
||||
|
||||
self.db.add(invoice)
|
||||
self.db.commit()
|
||||
|
||||
return invoice
|
||||
|
||||
def _generate_invoice_number(self, settings: StoreVATSettings) -> str:
|
||||
"""Generate next invoice number and increment counter."""
|
||||
year = date.today().year
|
||||
number = settings.invoice_next_number
|
||||
|
||||
invoice_number = f"{settings.invoice_prefix}-{year}-{number:04d}"
|
||||
|
||||
settings.invoice_next_number += 1
|
||||
self.db.commit()
|
||||
|
||||
return invoice_number
|
||||
|
||||
def _snapshot_seller(self, settings: StoreVATSettings) -> dict:
|
||||
"""Capture seller details at invoice time."""
|
||||
return {
|
||||
'merchant_name': settings.merchant_name,
|
||||
'address': settings.merchant_address,
|
||||
'city': settings.merchant_city,
|
||||
'postal_code': settings.merchant_postal_code,
|
||||
'country': settings.merchant_country,
|
||||
'vat_number': settings.vat_number
|
||||
}
|
||||
|
||||
def _snapshot_buyer(self, order: Order) -> dict:
|
||||
"""Capture buyer details at invoice time."""
|
||||
return {
|
||||
'name': f"{order.shipping_first_name} {order.shipping_last_name}",
|
||||
'merchant': order.shipping_merchant,
|
||||
'address': order.shipping_address,
|
||||
'city': order.shipping_city,
|
||||
'postal_code': order.shipping_postal_code,
|
||||
'country': order.shipping_country,
|
||||
'vat_number': order.customer_vat_number
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PDF Generation
|
||||
|
||||
### Using WeasyPrint
|
||||
|
||||
```python
|
||||
# app/services/invoice_pdf_service.py
|
||||
|
||||
from weasyprint import HTML, CSS
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
class InvoicePDFService:
|
||||
"""Generate PDF invoices."""
|
||||
|
||||
def __init__(self, template_dir: str = 'app/templates/invoices'):
|
||||
self.env = Environment(loader=FileSystemLoader(template_dir))
|
||||
|
||||
def generate_pdf(self, invoice: Invoice) -> bytes:
|
||||
"""Generate PDF bytes from invoice."""
|
||||
template = self.env.get_template('invoice.html')
|
||||
|
||||
html_content = template.render(
|
||||
invoice=invoice,
|
||||
seller=invoice.seller_details,
|
||||
buyer=invoice.buyer_details,
|
||||
items=invoice.line_items['items'],
|
||||
vat_label=self._get_vat_label(invoice.vat_regime)
|
||||
)
|
||||
|
||||
pdf_bytes = HTML(string=html_content).write_pdf(
|
||||
stylesheets=[CSS(filename='app/static/css/invoice.css')]
|
||||
)
|
||||
|
||||
return pdf_bytes
|
||||
|
||||
def _get_vat_label(self, regime: str) -> str:
|
||||
"""Human-readable VAT regime label."""
|
||||
labels = {
|
||||
'domestic': 'TVA Luxembourg',
|
||||
'oss': 'TVA (OSS - pays de destination)',
|
||||
'reverse_charge': 'Autoliquidation (Reverse Charge)',
|
||||
'origin': 'TVA pays d\'origine'
|
||||
}
|
||||
return labels.get(regime, 'TVA')
|
||||
```
|
||||
|
||||
### Invoice HTML Template
|
||||
|
||||
```html
|
||||
<!-- app/templates/invoices/invoice.html -->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; font-size: 11pt; }
|
||||
.header { display: flex; justify-content: space-between; margin-bottom: 30px; }
|
||||
.invoice-title { font-size: 24pt; color: #333; }
|
||||
.parties { display: flex; justify-content: space-between; margin-bottom: 30px; }
|
||||
.party-box { width: 45%; }
|
||||
.party-label { font-weight: bold; color: #666; margin-bottom: 5px; }
|
||||
table { width: 100%; border-collapse: collapse; margin-bottom: 30px; }
|
||||
th { background: #f5f5f5; padding: 10px; text-align: left; border-bottom: 2px solid #ddd; }
|
||||
td { padding: 10px; border-bottom: 1px solid #eee; }
|
||||
.amount { text-align: right; }
|
||||
.totals { width: 300px; margin-left: auto; }
|
||||
.totals td { padding: 5px 10px; }
|
||||
.totals .total-row { font-weight: bold; font-size: 14pt; border-top: 2px solid #333; }
|
||||
.footer { margin-top: 50px; font-size: 9pt; color: #666; }
|
||||
.vat-note { background: #f9f9f9; padding: 10px; margin-top: 20px; font-size: 9pt; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="invoice-title">FACTURE</div>
|
||||
<div>
|
||||
<strong>{{ invoice.invoice_number }}</strong><br>
|
||||
Date: {{ invoice.invoice_date.strftime('%d/%m/%Y') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="parties">
|
||||
<div class="party-box">
|
||||
<div class="party-label">De:</div>
|
||||
<strong>{{ seller.merchant_name }}</strong><br>
|
||||
{{ seller.address }}<br>
|
||||
{{ seller.postal_code }} {{ seller.city }}<br>
|
||||
{{ seller.country }}<br>
|
||||
{% if seller.vat_number %}TVA: {{ seller.vat_number }}{% endif %}
|
||||
</div>
|
||||
<div class="party-box">
|
||||
<div class="party-label">Facturé à:</div>
|
||||
<strong>{{ buyer.name }}</strong><br>
|
||||
{% if buyer.merchant %}{{ buyer.merchant }}<br>{% endif %}
|
||||
{{ buyer.address }}<br>
|
||||
{{ buyer.postal_code }} {{ buyer.city }}<br>
|
||||
{{ buyer.country }}<br>
|
||||
{% if buyer.vat_number %}TVA: {{ buyer.vat_number }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if invoice.order_id %}
|
||||
<p><strong>Référence commande:</strong> {{ invoice.order_id }}</p>
|
||||
{% endif %}
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th>Qté</th>
|
||||
<th class="amount">Prix unit. HT</th>
|
||||
<th class="amount">Total HT</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in items %}
|
||||
<tr>
|
||||
<td>{{ item.description }}{% if item.sku %} <small>({{ item.sku }})</small>{% endif %}</td>
|
||||
<td>{{ item.quantity }}</td>
|
||||
<td class="amount">€{{ "%.2f"|format(item.unit_price_net) }}</td>
|
||||
<td class="amount">€{{ "%.2f"|format(item.line_total_net) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table class="totals">
|
||||
<tr>
|
||||
<td>Sous-total HT:</td>
|
||||
<td class="amount">€{{ "%.2f"|format(invoice.subtotal_net) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ vat_label }} ({{ invoice.vat_rate }}%):</td>
|
||||
<td class="amount">€{{ "%.2f"|format(invoice.vat_amount) }}</td>
|
||||
</tr>
|
||||
<tr class="total-row">
|
||||
<td>TOTAL TTC:</td>
|
||||
<td class="amount">€{{ "%.2f"|format(invoice.total_gross) }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{% if invoice.vat_regime == 'reverse_charge' %}
|
||||
<div class="vat-note">
|
||||
<strong>Autoliquidation de la TVA</strong><br>
|
||||
En application de l'article 196 de la directive 2006/112/CE, la TVA est due par le preneur.
|
||||
</div>
|
||||
{% elif invoice.vat_regime == 'oss' %}
|
||||
<div class="vat-note">
|
||||
<strong>Régime OSS (One-Stop-Shop)</strong><br>
|
||||
TVA calculée selon le taux du pays de destination ({{ invoice.destination_country }}).
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="footer">
|
||||
{% if seller.payment_terms %}{{ seller.payment_terms }}<br>{% endif %}
|
||||
{% if seller.bank_details %}{{ seller.bank_details }}<br>{% endif %}
|
||||
{% if seller.footer_text %}{{ seller.footer_text }}{% endif %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
```python
|
||||
# app/api/v1/store/invoices.py
|
||||
|
||||
@router.post("/orders/{order_id}/invoice")
|
||||
async def create_invoice_from_order(
|
||||
order_id: UUID,
|
||||
store: Store = Depends(get_current_store),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Generate invoice for an order."""
|
||||
service = InvoiceService(db, VATService(db))
|
||||
invoice = service.create_invoice_from_order(order_id, store.id)
|
||||
return InvoiceResponse.from_orm(invoice)
|
||||
|
||||
@router.get("/invoices/{invoice_id}/pdf")
|
||||
async def download_invoice_pdf(
|
||||
invoice_id: UUID,
|
||||
store: Store = Depends(get_current_store),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Download invoice as PDF."""
|
||||
invoice = db.query(Invoice).filter(
|
||||
Invoice.id == invoice_id,
|
||||
Invoice.store_id == store.id
|
||||
).first()
|
||||
|
||||
if not invoice:
|
||||
raise HTTPException(404, "Invoice not found")
|
||||
|
||||
pdf_service = InvoicePDFService()
|
||||
pdf_bytes = pdf_service.generate_pdf(invoice)
|
||||
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={invoice.invoice_number}.pdf"
|
||||
}
|
||||
)
|
||||
|
||||
@router.get("/invoices")
|
||||
async def list_invoices(
|
||||
store: Store = Depends(get_current_store),
|
||||
db: Session = Depends(get_db),
|
||||
skip: int = 0,
|
||||
limit: int = 50
|
||||
):
|
||||
"""List all invoices for store."""
|
||||
invoices = db.query(Invoice).filter(
|
||||
Invoice.store_id == store.id
|
||||
).order_by(Invoice.invoice_date.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
return [InvoiceResponse.from_orm(inv) for inv in invoices]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UI Integration
|
||||
|
||||
### Order Detail - Invoice Button
|
||||
|
||||
```html
|
||||
<!-- In order detail view -->
|
||||
{% if not order.invoice %}
|
||||
<button
|
||||
@click="generateInvoice('{{ order.id }}')"
|
||||
class="btn btn-secondary">
|
||||
<i data-lucide="file-text"></i>
|
||||
Generate Invoice
|
||||
</button>
|
||||
{% else %}
|
||||
<a
|
||||
href="/api/v1/store/invoices/{{ order.invoice.id }}/pdf"
|
||||
class="btn btn-secondary"
|
||||
target="_blank">
|
||||
<i data-lucide="download"></i>
|
||||
Download Invoice ({{ order.invoice.invoice_number }})
|
||||
</a>
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
### Store Settings - VAT Configuration
|
||||
|
||||
```html
|
||||
<!-- New settings tab for VAT/Invoice configuration -->
|
||||
<div class="settings-section">
|
||||
<h3>Invoice Settings</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Merchant Name (for invoices)</label>
|
||||
<input type="text" x-model="settings.merchant_name" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Merchant Address</label>
|
||||
<textarea x-model="settings.merchant_address" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Postal Code</label>
|
||||
<input type="text" x-model="settings.merchant_postal_code">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>City</label>
|
||||
<input type="text" x-model="settings.merchant_city">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>VAT Number</label>
|
||||
<input type="text" x-model="settings.vat_number" placeholder="LU12345678">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" x-model="settings.is_oss_registered">
|
||||
Registered for OSS (One-Stop-Shop)
|
||||
</label>
|
||||
<small>Check this if you're registered to report EU VAT through the OSS system</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Invoice Number Prefix</label>
|
||||
<input type="text" x-model="settings.invoice_prefix" placeholder="INV">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Payment Terms</label>
|
||||
<input type="text" x-model="settings.payment_terms" placeholder="Due upon receipt">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Bank Details (optional)</label>
|
||||
<textarea x-model="settings.bank_details" rows="2" placeholder="IBAN: LU..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Effort
|
||||
|
||||
| Component | Estimate |
|
||||
|-----------|----------|
|
||||
| Database migrations | 0.5 day |
|
||||
| VAT rates seed data | 0.5 day |
|
||||
| VATService | 1 day |
|
||||
| InvoiceService | 1 day |
|
||||
| PDF generation | 1.5 days |
|
||||
| API endpoints | 0.5 day |
|
||||
| UI (settings + order button) | 1 day |
|
||||
| Testing | 1 day |
|
||||
| **Total** | **~7 days** |
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **VIES VAT Validation** - Verify B2B VAT numbers via EU API
|
||||
2. **Credit Notes** - For returns/refunds
|
||||
3. **Batch Invoice Generation** - Generate for multiple orders
|
||||
4. **Email Delivery** - Send invoice PDF by email
|
||||
5. **Accounting Export** - CSV/XML for accounting software
|
||||
This document has moved to the orders module docs: [VAT Invoicing](../modules/orders/vat-invoicing.md)
|
||||
|
||||
Reference in New Issue
Block a user