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:
345
app/modules/orders/docs/architecture.md
Normal file
345
app/modules/orders/docs/architecture.md
Normal file
@@ -0,0 +1,345 @@
|
||||
# Customer-Orders Architecture
|
||||
|
||||
This document describes the consumer-agnostic customer architecture, following the same pattern as [Media Architecture](../../architecture/media-architecture.md).
|
||||
|
||||
## Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ CONSUMER MODULES │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ Orders │ │ Loyalty │ │ Future │ │
|
||||
│ │ │ │ (future) │ │ Module │ │
|
||||
│ │ Order model │ │LoyaltyPoints│ │ XxxCustomer │ │
|
||||
│ │ (customer_id)│ │(customer_id)│ │ │ │
|
||||
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
|
||||
│ │ │ │ │
|
||||
│ └──────────────────┼──────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Customers Module │ │
|
||||
│ │ │ │
|
||||
│ │ Customer (generic, consumer-agnostic storage) │ │
|
||||
│ │ CustomerService (CRUD, authentication, profile management) │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Design Principles
|
||||
|
||||
### 1. Consumer-Agnostic Customer Storage
|
||||
|
||||
The customers module provides **generic customer storage** without knowing what entities will reference customers:
|
||||
|
||||
- `Customer` stores customer data (email, name, addresses, preferences)
|
||||
- `CustomerService` handles CRUD, authentication, and profile management
|
||||
- Customers module has **no knowledge** of orders, loyalty points, or any specific consumers
|
||||
|
||||
### 2. Consumer-Owned Relationships
|
||||
|
||||
Each module that references customers defines its **own relationship**:
|
||||
|
||||
- **Orders**: `Order.customer_id` links orders to customers
|
||||
- **Future Loyalty**: Would define `LoyaltyPoints.customer_id`
|
||||
- **Future Subscriptions**: Would define `Subscription.customer_id`
|
||||
|
||||
This follows the principle: **The consumer knows what it needs, the provider doesn't need to know who uses it.**
|
||||
|
||||
### 3. Correct Dependency Direction
|
||||
|
||||
```
|
||||
WRONG (Hidden Dependency):
|
||||
Customers → Orders (customers imports Order model)
|
||||
|
||||
CORRECT:
|
||||
Orders → Customers (orders references Customer via FK)
|
||||
```
|
||||
|
||||
Optional modules (orders) depend on core modules (customers), never the reverse.
|
||||
|
||||
## Key Components
|
||||
|
||||
### Customer Model (Customers Module)
|
||||
|
||||
```python
|
||||
# app/modules/customers/models/customer.py
|
||||
|
||||
class Customer(Base, TimestampMixin):
|
||||
"""Generic customer - consumer-agnostic."""
|
||||
|
||||
__tablename__ = "customers"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
store_id = Column(Integer, ForeignKey("stores.id"), nullable=False)
|
||||
|
||||
# Authentication
|
||||
email = Column(String(255), nullable=False)
|
||||
hashed_password = Column(String(255))
|
||||
|
||||
# Profile
|
||||
first_name = Column(String(100))
|
||||
last_name = Column(String(100))
|
||||
phone = Column(String(50))
|
||||
customer_number = Column(String(50), unique=True)
|
||||
|
||||
# Preferences
|
||||
marketing_consent = Column(Boolean, default=False)
|
||||
preferred_language = Column(String(10))
|
||||
|
||||
# Status
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
# Note: Consumer-specific relationships (orders, loyalty points, etc.)
|
||||
# are defined in their respective modules. Customers module doesn't
|
||||
# know about specific consumers.
|
||||
```
|
||||
|
||||
### CustomerService (Customers Module)
|
||||
|
||||
The `CustomerService` provides generic operations:
|
||||
|
||||
```python
|
||||
# app/modules/customers/services/customer_service.py
|
||||
|
||||
class CustomerService:
|
||||
"""Generic customer operations - consumer-agnostic."""
|
||||
|
||||
def create_customer(self, db, store_id, customer_data):
|
||||
"""Create a new customer."""
|
||||
...
|
||||
|
||||
def get_customer(self, db, store_id, customer_id):
|
||||
"""Get a customer by ID."""
|
||||
...
|
||||
|
||||
def update_customer(self, db, store_id, customer_id, customer_data):
|
||||
"""Update customer profile."""
|
||||
...
|
||||
|
||||
def login_customer(self, db, store_id, email, password):
|
||||
"""Authenticate a customer."""
|
||||
...
|
||||
|
||||
# Note: Customer order methods have been moved to the orders module.
|
||||
# Use orders.services.customer_order_service for order-related operations.
|
||||
```
|
||||
|
||||
### Order Model (Orders Module)
|
||||
|
||||
```python
|
||||
# app/modules/orders/models/order.py
|
||||
|
||||
class Order(Base, TimestampMixin):
|
||||
"""Order with customer reference - orders owns the relationship."""
|
||||
|
||||
__tablename__ = "orders"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
store_id = Column(Integer, ForeignKey("stores.id"), nullable=False)
|
||||
|
||||
# Customer reference - orders module owns this relationship
|
||||
customer_id = Column(Integer, ForeignKey("customers.id"))
|
||||
|
||||
# Order data
|
||||
order_number = Column(String(50), unique=True)
|
||||
status = Column(String(20), default="pending")
|
||||
total_cents = Column(Integer)
|
||||
...
|
||||
|
||||
# Relationship to customer
|
||||
customer = relationship("Customer", lazy="joined")
|
||||
```
|
||||
|
||||
### CustomerOrderService (Orders Module)
|
||||
|
||||
```python
|
||||
# app/modules/orders/services/customer_order_service.py
|
||||
|
||||
class CustomerOrderService:
|
||||
"""Customer-order operations - owned by orders module."""
|
||||
|
||||
def get_customer_orders(self, db, store_id, customer_id, skip=0, limit=50):
|
||||
"""Get orders for a specific customer."""
|
||||
...
|
||||
|
||||
def get_recent_orders(self, db, store_id, customer_id, limit=5):
|
||||
"""Get recent orders for a customer."""
|
||||
...
|
||||
|
||||
def get_order_count(self, db, store_id, customer_id):
|
||||
"""Get total order count for a customer."""
|
||||
...
|
||||
```
|
||||
|
||||
### Customer Order Metrics (Orders Module)
|
||||
|
||||
Order statistics for customers use the MetricsProvider pattern:
|
||||
|
||||
```python
|
||||
# app/modules/orders/services/order_metrics.py
|
||||
|
||||
class OrderMetricsProvider:
|
||||
"""Metrics provider including customer-level order metrics."""
|
||||
|
||||
def get_customer_order_metrics(self, db, store_id, customer_id, context=None):
|
||||
"""
|
||||
Get order metrics for a specific customer.
|
||||
|
||||
Returns MetricValue objects for:
|
||||
- total_orders: Total orders placed
|
||||
- total_spent: Total amount spent
|
||||
- avg_order_value: Average order value
|
||||
- last_order_date: Date of most recent order
|
||||
- first_order_date: Date of first order
|
||||
"""
|
||||
...
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Customers Module Endpoints
|
||||
|
||||
Customer CRUD operations (no order data):
|
||||
|
||||
```
|
||||
GET /api/store/customers → List customers
|
||||
GET /api/store/customers/{id} → Customer details (no order stats)
|
||||
PUT /api/store/customers/{id} → Update customer
|
||||
PUT /api/store/customers/{id}/status → Toggle active status
|
||||
```
|
||||
|
||||
### Orders Module Endpoints
|
||||
|
||||
Customer order data (owned by orders):
|
||||
|
||||
```
|
||||
GET /api/store/customers/{id}/orders → Customer's order history
|
||||
GET /api/store/customers/{id}/order-stats → Customer's order statistics
|
||||
```
|
||||
|
||||
## Adding Customer References to a New Module
|
||||
|
||||
When creating a module that references customers (e.g., a loyalty module):
|
||||
|
||||
### Step 1: Reference Customer via Foreign Key
|
||||
|
||||
```python
|
||||
# app/modules/loyalty/models/loyalty_points.py
|
||||
|
||||
class LoyaltyPoints(Base):
|
||||
"""Loyalty points - owned by loyalty module."""
|
||||
|
||||
__tablename__ = "loyalty_points"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
store_id = Column(Integer, ForeignKey("stores.id"))
|
||||
|
||||
# Reference to customer - loyalty module owns this
|
||||
customer_id = Column(Integer, ForeignKey("customers.id"))
|
||||
|
||||
points_balance = Column(Integer, default=0)
|
||||
tier = Column(String(20), default="bronze")
|
||||
|
||||
# Relationship
|
||||
customer = relationship("Customer", lazy="joined")
|
||||
```
|
||||
|
||||
### Step 2: Create Your Service
|
||||
|
||||
```python
|
||||
# app/modules/loyalty/services/customer_loyalty_service.py
|
||||
|
||||
class CustomerLoyaltyService:
|
||||
"""Customer loyalty operations - owned by loyalty module."""
|
||||
|
||||
def get_customer_points(self, db, store_id, customer_id):
|
||||
"""Get loyalty points for a customer."""
|
||||
...
|
||||
|
||||
def add_points(self, db, store_id, customer_id, points, reason):
|
||||
"""Add points to customer's balance."""
|
||||
...
|
||||
```
|
||||
|
||||
### Step 3: Add Routes in Your Module
|
||||
|
||||
```python
|
||||
# app/modules/loyalty/routes/api/store.py
|
||||
|
||||
@router.get("/customers/{customer_id}/loyalty")
|
||||
def get_customer_loyalty(customer_id: int, ...):
|
||||
"""Get loyalty information for a customer."""
|
||||
return loyalty_service.get_customer_points(db, store_id, customer_id)
|
||||
```
|
||||
|
||||
## Benefits of This Architecture
|
||||
|
||||
1. **Module Independence**: Orders can be disabled without affecting customers
|
||||
2. **Extensibility**: New modules easily reference customers
|
||||
3. **No Hidden Dependencies**: Dependencies flow in one direction
|
||||
4. **Clean Separation**: Customers handles identity, consumers handle their domain
|
||||
5. **Testability**: Can test customers without any consumer modules
|
||||
6. **Single Responsibility**: Each module owns its domain
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
### Don't: Import Consumer Models in Customers
|
||||
|
||||
```python
|
||||
# BAD - Creates hidden dependency
|
||||
class CustomerService:
|
||||
def get_customer_orders(self, db, customer_id):
|
||||
from app.modules.orders.models import Order # Wrong!
|
||||
return db.query(Order).filter(Order.customer_id == customer_id).all()
|
||||
```
|
||||
|
||||
### Don't: Add Consumer-Specific Fields to Customer
|
||||
|
||||
```python
|
||||
# BAD - Customer shouldn't know about orders
|
||||
class Customer(Base):
|
||||
# These create coupling to orders module
|
||||
total_orders = Column(Integer) # Wrong approach
|
||||
last_order_date = Column(DateTime) # Wrong approach
|
||||
```
|
||||
|
||||
Instead, query order data from the orders module when needed.
|
||||
|
||||
### Don't: Put Consumer Routes in Customers Module
|
||||
|
||||
```python
|
||||
# BAD - customers/routes shouldn't serve order data
|
||||
@router.get("/customers/{id}/orders")
|
||||
def get_customer_orders(customer_id: int):
|
||||
from app.modules.orders.models import Order # Wrong!
|
||||
...
|
||||
```
|
||||
|
||||
## Migration Note
|
||||
|
||||
Previously, the customers module had methods that imported from orders:
|
||||
|
||||
```python
|
||||
# OLD (removed)
|
||||
class CustomerService:
|
||||
def get_customer_orders(self, db, store_id, customer_id):
|
||||
from app.modules.orders.models import Order # Lazy import
|
||||
...
|
||||
|
||||
def get_customer_statistics(self, db, store_id, customer_id):
|
||||
from app.modules.orders.models import Order # Lazy import
|
||||
...
|
||||
```
|
||||
|
||||
These have been moved to the orders module:
|
||||
- `get_customer_orders()` → `orders.services.customer_order_service`
|
||||
- `get_customer_statistics()` → `orders.services.order_metrics.get_customer_order_metrics()`
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Media Architecture](../../architecture/media-architecture.md) - Similar pattern for media files
|
||||
- [Module System Architecture](../../architecture/module-system.md) - Module structure and dependencies
|
||||
- [Cross-Module Import Rules](../../architecture/cross-module-import-rules.md) - Import restrictions
|
||||
- [Metrics Provider Pattern](../../architecture/metrics-provider-pattern.md) - Provider pattern for statistics
|
||||
229
app/modules/orders/docs/data-model.md
Normal file
229
app/modules/orders/docs/data-model.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# Orders Data Model
|
||||
|
||||
Entity relationships and database schema for the orders module.
|
||||
|
||||
## Entity Relationship Overview
|
||||
|
||||
```
|
||||
Store 1──* Order 1──* OrderItem *──1 Product
|
||||
│ │
|
||||
│ └──? OrderItemException
|
||||
│
|
||||
└──* Invoice
|
||||
|
||||
Store 1──1 StoreInvoiceSettings
|
||||
Store 1──* CustomerOrderStats *──1 Customer
|
||||
```
|
||||
|
||||
## Models
|
||||
|
||||
### Order
|
||||
|
||||
Unified order model for all sales channels (direct store orders and marketplace orders). Stores customer and address data as snapshots at order time. All monetary amounts are stored as integer cents (e.g., €105.91 = 10591).
|
||||
|
||||
| Field | Type | Constraints | Description |
|
||||
|-------|------|-------------|-------------|
|
||||
| `id` | Integer | PK | Primary key |
|
||||
| `store_id` | Integer | FK, not null, indexed | Reference to store |
|
||||
| `customer_id` | Integer | FK, not null, indexed | Reference to customer |
|
||||
| `order_number` | String(100) | not null, indexed | Unique order identifier |
|
||||
| `channel` | String(50) | default "direct", indexed | Order source: "direct" or "letzshop" |
|
||||
| `external_order_id` | String(100) | nullable, indexed | Marketplace order ID |
|
||||
| `external_shipment_id` | String(100) | nullable, indexed | Marketplace shipment ID |
|
||||
| `external_order_number` | String(100) | nullable | Marketplace order number |
|
||||
| `external_data` | JSON | nullable | Raw marketplace data for debugging |
|
||||
| `status` | String(50) | default "pending", indexed | pending, processing, shipped, delivered, cancelled, refunded |
|
||||
| `subtotal_cents` | Integer | nullable | Subtotal in cents |
|
||||
| `tax_amount_cents` | Integer | nullable | Tax in cents |
|
||||
| `shipping_amount_cents` | Integer | nullable | Shipping cost in cents |
|
||||
| `discount_amount_cents` | Integer | nullable | Discount in cents |
|
||||
| `total_amount_cents` | Integer | not null | Total in cents |
|
||||
| `currency` | String(10) | default "EUR" | Currency code |
|
||||
| `vat_regime` | String(20) | nullable | domestic, oss, reverse_charge, origin, exempt |
|
||||
| `vat_rate` | Numeric(5,2) | nullable | VAT rate percentage |
|
||||
| `vat_rate_label` | String(100) | nullable | Human-readable VAT label |
|
||||
| `vat_destination_country` | String(2) | nullable | Destination country ISO code |
|
||||
| `customer_first_name` | String(100) | not null | Customer first name snapshot |
|
||||
| `customer_last_name` | String(100) | not null | Customer last name snapshot |
|
||||
| `customer_email` | String(255) | not null | Customer email snapshot |
|
||||
| `customer_phone` | String(50) | nullable | Customer phone |
|
||||
| `customer_locale` | String(10) | nullable | Customer locale (en, fr, de, lb) |
|
||||
| `ship_*` | Various | not null | Shipping address fields (first_name, last_name, company, address_line_1/2, city, postal_code, country_iso) |
|
||||
| `bill_*` | Various | not null | Billing address fields (same structure as shipping) |
|
||||
| `shipping_method` | String(100) | nullable | Shipping method |
|
||||
| `tracking_number` | String(100) | nullable | Tracking number |
|
||||
| `tracking_provider` | String(100) | nullable | Tracking provider |
|
||||
| `tracking_url` | String(500) | nullable | Full tracking URL |
|
||||
| `shipment_number` | String(100) | nullable | Carrier shipment number |
|
||||
| `shipping_carrier` | String(50) | nullable | Carrier code (greco, colissimo, etc.) |
|
||||
| `customer_notes` | Text | nullable | Notes from customer |
|
||||
| `internal_notes` | Text | nullable | Internal notes |
|
||||
| `order_date` | DateTime | not null, tz-aware | When customer placed order |
|
||||
| `confirmed_at` | DateTime | nullable, tz-aware | When order was confirmed |
|
||||
| `shipped_at` | DateTime | nullable, tz-aware | When order was shipped |
|
||||
| `delivered_at` | DateTime | nullable, tz-aware | When order was delivered |
|
||||
| `cancelled_at` | DateTime | nullable, tz-aware | When order was cancelled |
|
||||
| `created_at` | DateTime | tz-aware | Record creation time |
|
||||
| `updated_at` | DateTime | tz-aware | Record update time |
|
||||
|
||||
**Composite Indexes**: `(store_id, status)`, `(store_id, channel)`, `(store_id, order_date)`
|
||||
|
||||
### OrderItem
|
||||
|
||||
Individual line items in an order with product snapshot at order time.
|
||||
|
||||
| Field | Type | Constraints | Description |
|
||||
|-------|------|-------------|-------------|
|
||||
| `id` | Integer | PK | Primary key |
|
||||
| `order_id` | Integer | FK, not null, indexed | Reference to order |
|
||||
| `product_id` | Integer | FK, not null | Reference to product |
|
||||
| `product_name` | String(255) | not null | Product name snapshot |
|
||||
| `product_sku` | String(100) | nullable | Product SKU snapshot |
|
||||
| `gtin` | String(50) | nullable | EAN/UPC/ISBN code |
|
||||
| `gtin_type` | String(20) | nullable | GTIN type (ean13, upc, isbn, etc.) |
|
||||
| `quantity` | Integer | not null | Units ordered |
|
||||
| `unit_price_cents` | Integer | not null | Price per unit in cents |
|
||||
| `total_price_cents` | Integer | not null | Total price for line in cents |
|
||||
| `external_item_id` | String(100) | nullable | Marketplace inventory unit ID |
|
||||
| `external_variant_id` | String(100) | nullable | Marketplace variant ID |
|
||||
| `item_state` | String(50) | nullable | confirmed_available or confirmed_unavailable |
|
||||
| `inventory_reserved` | Boolean | default False | Whether inventory is reserved |
|
||||
| `inventory_fulfilled` | Boolean | default False | Whether inventory is fulfilled |
|
||||
| `shipped_quantity` | Integer | default 0, not null | Units shipped so far |
|
||||
| `needs_product_match` | Boolean | default False, indexed | Product not found by GTIN during import |
|
||||
| `created_at` | DateTime | tz-aware | Record creation time |
|
||||
| `updated_at` | DateTime | tz-aware | Record update time |
|
||||
|
||||
### OrderItemException
|
||||
|
||||
Tracks unmatched order items requiring admin/store resolution. Created when a marketplace order contains a GTIN that doesn't match any product in the store's catalog.
|
||||
|
||||
| Field | Type | Constraints | Description |
|
||||
|-------|------|-------------|-------------|
|
||||
| `id` | Integer | PK | Primary key |
|
||||
| `order_item_id` | Integer | FK, unique, not null | Reference to order item (cascade delete) |
|
||||
| `store_id` | Integer | FK, not null, indexed | Denormalized store reference |
|
||||
| `original_gtin` | String(50) | nullable, indexed | Original GTIN from marketplace |
|
||||
| `original_product_name` | String(500) | nullable | Original product name from marketplace |
|
||||
| `original_sku` | String(100) | nullable | Original SKU from marketplace |
|
||||
| `exception_type` | String(50) | default "product_not_found", not null | product_not_found, gtin_mismatch, duplicate_gtin |
|
||||
| `status` | String(50) | default "pending", not null, indexed | pending, resolved, ignored |
|
||||
| `resolved_product_id` | Integer | FK, nullable | Assigned product after resolution |
|
||||
| `resolved_at` | DateTime | nullable, tz-aware | When exception was resolved |
|
||||
| `resolved_by` | Integer | FK, nullable | Who resolved it |
|
||||
| `resolution_notes` | Text | nullable | Notes about the resolution |
|
||||
| `created_at` | DateTime | tz-aware | Record creation time |
|
||||
| `updated_at` | DateTime | tz-aware | Record update time |
|
||||
|
||||
**Composite Indexes**: `(store_id, status)`, `(store_id, original_gtin)`
|
||||
|
||||
### Invoice
|
||||
|
||||
Invoice record with snapshots of seller/buyer details. Stores complete invoice data including snapshots at creation time for audit.
|
||||
|
||||
| Field | Type | Constraints | Description |
|
||||
|-------|------|-------------|-------------|
|
||||
| `id` | Integer | PK | Primary key |
|
||||
| `store_id` | Integer | FK, not null, indexed | Reference to store |
|
||||
| `order_id` | Integer | FK, nullable, indexed | Reference to order |
|
||||
| `invoice_number` | String(50) | not null | Invoice identifier |
|
||||
| `invoice_date` | DateTime | not null, tz-aware | Date of invoice |
|
||||
| `status` | String(20) | default "draft", not null | draft, issued, paid, cancelled |
|
||||
| `seller_details` | JSON | not null | Snapshot: {merchant_name, address, city, postal_code, country, vat_number} |
|
||||
| `buyer_details` | JSON | not null | Snapshot: {name, email, address, city, postal_code, country, vat_number} |
|
||||
| `line_items` | JSON | not null | Snapshot: [{description, quantity, unit_price_cents, total_cents, sku, ean}] |
|
||||
| `vat_regime` | String(20) | default "domestic", not null | domestic, oss, reverse_charge, origin, exempt |
|
||||
| `destination_country` | String(2) | nullable | Destination country ISO for OSS |
|
||||
| `vat_rate` | Numeric(5,2) | not null | VAT rate percentage |
|
||||
| `vat_rate_label` | String(50) | nullable | Human-readable VAT rate label |
|
||||
| `currency` | String(3) | default "EUR", not null | Currency code |
|
||||
| `subtotal_cents` | Integer | not null | Subtotal before VAT in cents |
|
||||
| `vat_amount_cents` | Integer | not null | VAT amount in cents |
|
||||
| `total_cents` | Integer | not null | Total after VAT in cents |
|
||||
| `payment_terms` | Text | nullable | Payment terms description |
|
||||
| `bank_details` | JSON | nullable | IBAN and BIC snapshot |
|
||||
| `footer_text` | Text | nullable | Custom footer text |
|
||||
| `pdf_generated_at` | DateTime | nullable, tz-aware | When PDF was generated |
|
||||
| `pdf_path` | String(500) | nullable | Path to stored PDF |
|
||||
| `notes` | Text | nullable | Internal notes |
|
||||
| `created_at` | DateTime | tz-aware | Record creation time |
|
||||
| `updated_at` | DateTime | tz-aware | Record update time |
|
||||
|
||||
**Unique Constraint**: `(store_id, invoice_number)`
|
||||
**Composite Indexes**: `(store_id, invoice_date)`, `(store_id, status)`
|
||||
|
||||
### StoreInvoiceSettings
|
||||
|
||||
Per-store invoice configuration including merchant details, VAT number, invoice numbering, and payment information.
|
||||
|
||||
| Field | Type | Constraints | Description |
|
||||
|-------|------|-------------|-------------|
|
||||
| `id` | Integer | PK | Primary key |
|
||||
| `store_id` | Integer | FK, unique, not null | One-to-one with store |
|
||||
| `merchant_name` | String(255) | not null | Legal merchant name |
|
||||
| `merchant_address` | String(255) | nullable | Street address |
|
||||
| `merchant_city` | String(100) | nullable | City |
|
||||
| `merchant_postal_code` | String(20) | nullable | Postal code |
|
||||
| `merchant_country` | String(2) | default "LU", not null | ISO country code |
|
||||
| `vat_number` | String(50) | nullable | VAT number (e.g., "LU12345678") |
|
||||
| `is_vat_registered` | Boolean | default True, not null | VAT registration status |
|
||||
| `is_oss_registered` | Boolean | default False, not null | OSS registration status |
|
||||
| `oss_registration_country` | String(2) | nullable | OSS registration country |
|
||||
| `invoice_prefix` | String(20) | default "INV", not null | Invoice number prefix |
|
||||
| `invoice_next_number` | Integer | default 1, not null | Next invoice number counter |
|
||||
| `invoice_number_padding` | Integer | default 5, not null | Zero-padding width |
|
||||
| `payment_terms` | Text | nullable | Payment terms description |
|
||||
| `bank_name` | String(255) | nullable | Bank name |
|
||||
| `bank_iban` | String(50) | nullable | IBAN |
|
||||
| `bank_bic` | String(20) | nullable | BIC |
|
||||
| `footer_text` | Text | nullable | Custom footer text |
|
||||
| `default_vat_rate` | Numeric(5,2) | default 17.00, not null | Default VAT rate |
|
||||
| `created_at` | DateTime | tz-aware | Record creation time |
|
||||
| `updated_at` | DateTime | tz-aware | Record update time |
|
||||
|
||||
### CustomerOrderStats
|
||||
|
||||
Aggregated order statistics per customer per store. Separates order stats from customer profile data.
|
||||
|
||||
| Field | Type | Constraints | Description |
|
||||
|-------|------|-------------|-------------|
|
||||
| `id` | Integer | PK | Primary key |
|
||||
| `store_id` | Integer | FK, not null, indexed | Reference to store |
|
||||
| `customer_id` | Integer | FK, not null, indexed | Reference to customer |
|
||||
| `total_orders` | Integer | default 0, not null | Total number of orders |
|
||||
| `total_spent_cents` | Integer | default 0, not null | Total amount spent in cents |
|
||||
| `last_order_date` | DateTime | nullable, tz-aware | Date of most recent order |
|
||||
| `first_order_date` | DateTime | nullable, tz-aware | Date of first order |
|
||||
| `created_at` | DateTime | tz-aware | Record creation time |
|
||||
| `updated_at` | DateTime | tz-aware | Record update time |
|
||||
|
||||
**Unique Constraint**: `(store_id, customer_id)`
|
||||
|
||||
## Enums
|
||||
|
||||
### InvoiceStatus
|
||||
|
||||
| Value | Description |
|
||||
|-------|-------------|
|
||||
| `draft` | Invoice created but not finalized |
|
||||
| `issued` | Invoice sent to customer |
|
||||
| `paid` | Payment received |
|
||||
| `cancelled` | Invoice cancelled |
|
||||
|
||||
### VATRegime
|
||||
|
||||
| Value | Description |
|
||||
|-------|-------------|
|
||||
| `domestic` | Same country as seller |
|
||||
| `oss` | EU cross-border with OSS registration |
|
||||
| `reverse_charge` | B2B with valid VAT number |
|
||||
| `origin` | Cross-border without OSS (use origin VAT) |
|
||||
| `exempt` | VAT exempt |
|
||||
|
||||
## Design Patterns
|
||||
|
||||
- **Money as cents**: All monetary values stored as integer cents for precision
|
||||
- **Snapshots**: Customer, address, seller, and product data captured at order/invoice time
|
||||
- **Marketplace support**: External reference fields for marketplace-specific IDs and data
|
||||
- **Timezone-aware dates**: All DateTime fields include timezone info
|
||||
- **Composite indexes**: Optimized for common query patterns (store + status, store + date)
|
||||
288
app/modules/orders/docs/exceptions.md
Normal file
288
app/modules/orders/docs/exceptions.md
Normal file
@@ -0,0 +1,288 @@
|
||||
# Order Item Exception System
|
||||
|
||||
## Overview
|
||||
|
||||
The Order Item Exception system handles unmatched products during marketplace order imports. Instead of blocking imports when products cannot be found by GTIN, the system gracefully imports orders with placeholder products and creates exception records for QC resolution.
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Graceful Import** - Orders are imported even when products aren't found
|
||||
2. **Exception Tracking** - Unmatched items are tracked in `order_item_exceptions` table
|
||||
3. **Resolution Workflow** - Admin/store can assign correct products
|
||||
4. **Confirmation Blocking** - Orders with unresolved exceptions cannot be confirmed
|
||||
5. **Auto-Match** - Exceptions auto-resolve when matching products are imported
|
||||
|
||||
## Database Schema
|
||||
|
||||
### order_item_exceptions Table
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| id | Integer | Primary key |
|
||||
| order_item_id | Integer | FK to order_items (unique) |
|
||||
| store_id | Integer | FK to stores (indexed) |
|
||||
| original_gtin | String(50) | GTIN from marketplace |
|
||||
| original_product_name | String(500) | Product name from marketplace |
|
||||
| original_sku | String(100) | SKU from marketplace |
|
||||
| exception_type | String(50) | product_not_found, gtin_mismatch, duplicate_gtin |
|
||||
| status | String(50) | pending, resolved, ignored |
|
||||
| resolved_product_id | Integer | FK to products (nullable) |
|
||||
| resolved_at | DateTime | When resolved |
|
||||
| resolved_by | Integer | FK to users |
|
||||
| resolution_notes | Text | Optional notes |
|
||||
| created_at | DateTime | Created timestamp |
|
||||
| updated_at | DateTime | Updated timestamp |
|
||||
|
||||
### order_items Table (Modified)
|
||||
|
||||
Added column:
|
||||
- `needs_product_match: Boolean (default False, indexed)`
|
||||
|
||||
### Placeholder Product
|
||||
|
||||
Per-store placeholder with:
|
||||
- `gtin = "0000000000000"`
|
||||
- `gtin_type = "placeholder"`
|
||||
- `is_active = False`
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
Import Order from Marketplace
|
||||
│
|
||||
▼
|
||||
Query Products by GTIN
|
||||
│
|
||||
┌────┴────┐
|
||||
│ │
|
||||
Found Not Found
|
||||
│ │
|
||||
▼ ▼
|
||||
Normal Create with placeholder
|
||||
Item + Set needs_product_match=True
|
||||
+ Create OrderItemException
|
||||
│
|
||||
▼
|
||||
QC Dashboard shows pending
|
||||
│
|
||||
┌─────┴─────┐
|
||||
│ │
|
||||
Resolve Ignore
|
||||
(assign (with
|
||||
product) reason)
|
||||
│ │
|
||||
▼ ▼
|
||||
Update item Mark ignored
|
||||
product_id (still blocks)
|
||||
│
|
||||
▼
|
||||
Order can now be confirmed
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Admin Endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/v1/admin/order-exceptions` | List all exceptions |
|
||||
| GET | `/api/v1/admin/order-exceptions/stats` | Get exception statistics |
|
||||
| GET | `/api/v1/admin/order-exceptions/{id}` | Get exception details |
|
||||
| POST | `/api/v1/admin/order-exceptions/{id}/resolve` | Resolve with product |
|
||||
| POST | `/api/v1/admin/order-exceptions/{id}/ignore` | Mark as ignored |
|
||||
| POST | `/api/v1/admin/order-exceptions/bulk-resolve` | Bulk resolve by GTIN |
|
||||
|
||||
### Store Endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/v1/store/order-exceptions` | List store's exceptions |
|
||||
| GET | `/api/v1/store/order-exceptions/stats` | Get store's stats |
|
||||
| GET | `/api/v1/store/order-exceptions/{id}` | Get exception details |
|
||||
| POST | `/api/v1/store/order-exceptions/{id}/resolve` | Resolve with product |
|
||||
| POST | `/api/v1/store/order-exceptions/{id}/ignore` | Mark as ignored |
|
||||
| POST | `/api/v1/store/order-exceptions/bulk-resolve` | Bulk resolve by GTIN |
|
||||
|
||||
## Exception Types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `product_not_found` | GTIN not in store's product catalog |
|
||||
| `gtin_mismatch` | GTIN format issue |
|
||||
| `duplicate_gtin` | Multiple products with same GTIN |
|
||||
|
||||
## Exception Statuses
|
||||
|
||||
| Status | Description | Blocks Confirmation |
|
||||
|--------|-------------|---------------------|
|
||||
| `pending` | Awaiting resolution | Yes |
|
||||
| `resolved` | Product assigned | No |
|
||||
| `ignored` | Marked as ignored | Yes |
|
||||
|
||||
**Note:** Both `pending` and `ignored` statuses block order confirmation.
|
||||
|
||||
## Auto-Matching
|
||||
|
||||
When products are imported to the store catalog (via copy_to_store_catalog), the system automatically:
|
||||
|
||||
1. Collects GTINs of newly imported products
|
||||
2. Finds pending exceptions with matching GTINs
|
||||
3. Resolves them by assigning the new product
|
||||
|
||||
This happens automatically during:
|
||||
- Single product import
|
||||
- Bulk product import (marketplace sync)
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Order Creation (`app/services/order_service.py`)
|
||||
|
||||
The `create_letzshop_order()` method:
|
||||
1. Queries products by GTIN
|
||||
2. For missing GTINs, creates placeholder product
|
||||
3. Creates order items with `needs_product_match=True`
|
||||
4. Creates exception records
|
||||
|
||||
### Order Confirmation
|
||||
|
||||
Confirmation endpoints check for unresolved exceptions:
|
||||
- Admin: `app/api/v1/admin/letzshop.py`
|
||||
- Store: `app/api/v1/store/letzshop.py`
|
||||
|
||||
Raises `OrderHasUnresolvedExceptionsException` if exceptions exist.
|
||||
|
||||
### Product Import (`app/services/marketplace_product_service.py`)
|
||||
|
||||
The `copy_to_store_catalog()` method:
|
||||
1. Copies GTIN from MarketplaceProduct to Product
|
||||
2. Calls auto-match service after products are created
|
||||
3. Returns `auto_matched` count in response
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
### New Files
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `models/database/order_item_exception.py` | Database model |
|
||||
| `models/schema/order_item_exception.py` | Pydantic schemas |
|
||||
| `app/services/order_item_exception_service.py` | Business logic |
|
||||
| `app/exceptions/order_item_exception.py` | Domain exceptions |
|
||||
| `app/api/v1/admin/order_item_exceptions.py` | Admin endpoints |
|
||||
| `app/api/v1/store/order_item_exceptions.py` | Store endpoints |
|
||||
| `alembic/versions/d2e3f4a5b6c7_add_order_item_exceptions.py` | Migration |
|
||||
|
||||
### Modified Files
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `models/database/order.py` | Added `needs_product_match`, exception relationship |
|
||||
| `models/database/__init__.py` | Export OrderItemException |
|
||||
| `models/schema/order.py` | Added exception info to OrderItemResponse |
|
||||
| `app/services/order_service.py` | Graceful handling of missing products |
|
||||
| `app/services/marketplace_product_service.py` | Auto-match on product import |
|
||||
| `app/api/v1/admin/letzshop.py` | Confirmation blocking check |
|
||||
| `app/api/v1/store/letzshop.py` | Confirmation blocking check |
|
||||
| `app/api/v1/admin/__init__.py` | Register exception router |
|
||||
| `app/api/v1/store/__init__.py` | Register exception router |
|
||||
| `app/exceptions/__init__.py` | Export new exceptions |
|
||||
|
||||
## Response Examples
|
||||
|
||||
### List Exceptions
|
||||
|
||||
```json
|
||||
{
|
||||
"exceptions": [
|
||||
{
|
||||
"id": 1,
|
||||
"order_item_id": 42,
|
||||
"store_id": 1,
|
||||
"original_gtin": "4006381333931",
|
||||
"original_product_name": "Funko Pop! Marvel...",
|
||||
"original_sku": "MH-FU-56757",
|
||||
"exception_type": "product_not_found",
|
||||
"status": "pending",
|
||||
"order_number": "LS-1-R702236251",
|
||||
"order_date": "2025-12-19T10:30:00Z",
|
||||
"created_at": "2025-12-19T11:00:00Z"
|
||||
}
|
||||
],
|
||||
"total": 15,
|
||||
"skip": 0,
|
||||
"limit": 50
|
||||
}
|
||||
```
|
||||
|
||||
### Exception Stats
|
||||
|
||||
```json
|
||||
{
|
||||
"pending": 15,
|
||||
"resolved": 42,
|
||||
"ignored": 3,
|
||||
"total": 60,
|
||||
"orders_with_exceptions": 8
|
||||
}
|
||||
```
|
||||
|
||||
### Resolve Exception
|
||||
|
||||
```json
|
||||
POST /api/v1/admin/order-exceptions/1/resolve
|
||||
{
|
||||
"product_id": 123,
|
||||
"notes": "Matched to correct product manually"
|
||||
}
|
||||
```
|
||||
|
||||
### Bulk Resolve
|
||||
|
||||
```json
|
||||
POST /api/v1/admin/order-exceptions/bulk-resolve?store_id=1
|
||||
{
|
||||
"gtin": "4006381333931",
|
||||
"product_id": 123,
|
||||
"notes": "New product imported"
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"resolved_count": 5,
|
||||
"gtin": "4006381333931",
|
||||
"product_id": 123
|
||||
}
|
||||
```
|
||||
|
||||
## Admin UI
|
||||
|
||||
The exceptions tab is available in the Letzshop management page:
|
||||
|
||||
**Location:** `/admin/marketplace/letzshop` → Exceptions tab
|
||||
|
||||
### Features
|
||||
|
||||
- **Stats Cards**: Shows pending, resolved, ignored, and affected orders counts
|
||||
- **Filters**: Search by GTIN/product name/order number, filter by status
|
||||
- **Exception Table**: Paginated list with product info, GTIN, order link, status
|
||||
- **Actions**:
|
||||
- **Resolve**: Opens modal with product search (autocomplete)
|
||||
- **Ignore**: Marks exception as ignored (still blocks confirmation)
|
||||
- **Bulk Resolve**: Checkbox to apply resolution to all exceptions with same GTIN
|
||||
|
||||
### Files
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `app/templates/admin/partials/letzshop-exceptions-tab.html` | Tab HTML template |
|
||||
| `app/templates/admin/marketplace-letzshop.html` | Main page (includes tab) |
|
||||
| `static/admin/js/marketplace-letzshop.js` | JavaScript handlers |
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Exception | HTTP Status | When |
|
||||
|-----------|-------------|------|
|
||||
| `OrderItemExceptionNotFoundException` | 404 | Exception not found |
|
||||
| `OrderHasUnresolvedExceptionsException` | 400 | Trying to confirm order with exceptions |
|
||||
| `ExceptionAlreadyResolvedException` | 400 | Trying to resolve already resolved exception |
|
||||
| `InvalidProductForExceptionException` | 400 | Invalid product (wrong store, inactive) |
|
||||
67
app/modules/orders/docs/index.md
Normal file
67
app/modules/orders/docs/index.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Order Management
|
||||
|
||||
Order processing, fulfillment tracking, customer checkout, invoicing, and bulk order operations. Uses the payments module for checkout.
|
||||
|
||||
## Overview
|
||||
|
||||
| Aspect | Detail |
|
||||
|--------|--------|
|
||||
| Code | `orders` |
|
||||
| Classification | Optional |
|
||||
| Dependencies | `payments`, `catalog`, `inventory`, `marketplace` |
|
||||
| Status | Active |
|
||||
|
||||
## Features
|
||||
|
||||
- `order_management` — Order CRUD and status management
|
||||
- `order_bulk_actions` — Bulk order operations
|
||||
- `order_export` — Order data export
|
||||
- `automation_rules` — Order processing automation
|
||||
- `fulfillment_tracking` — Shipment and fulfillment tracking
|
||||
- `shipping_management` — Shipping method configuration
|
||||
- `order_exceptions` — Order item exception handling
|
||||
- `customer_checkout` — Customer-facing checkout
|
||||
- `invoice_generation` — Automatic invoice creation
|
||||
- `invoice_pdf` — PDF invoice generation
|
||||
|
||||
## Permissions
|
||||
|
||||
| Permission | Description |
|
||||
|------------|-------------|
|
||||
| `orders.view` | View orders |
|
||||
| `orders.edit` | Edit orders |
|
||||
| `orders.cancel` | Cancel orders |
|
||||
| `orders.refund` | Process refunds |
|
||||
|
||||
## Data Model
|
||||
|
||||
See [Data Model](data-model.md) for full entity relationships and schema.
|
||||
|
||||
- **Order** — Unified order model for direct and marketplace channels
|
||||
- **OrderItem** — Line items with product snapshots and shipment tracking
|
||||
- **OrderItemException** — Unmatched GTIN resolution for marketplace imports
|
||||
- **Invoice** — Invoice records with seller/buyer snapshots
|
||||
- **StoreInvoiceSettings** — Per-store invoice configuration and VAT settings
|
||||
- **CustomerOrderStats** — Aggregated per-customer order statistics
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `*` | `/api/v1/admin/orders/*` | Admin order management |
|
||||
| `*` | `/api/v1/admin/order-exceptions/*` | Exception management |
|
||||
| `*` | `/api/v1/store/customer-orders/*` | Store customer order views |
|
||||
|
||||
## Configuration
|
||||
|
||||
No module-specific configuration.
|
||||
|
||||
## Additional Documentation
|
||||
|
||||
- [Data Model](data-model.md) — Entity relationships and database schema
|
||||
- [Architecture](architecture.md) — Consumer-agnostic customer architecture
|
||||
- [Unified Order View](unified-order-view.md) — Unified order schema with snapshots
|
||||
- [Order Item Exceptions](exceptions.md) — Exception system for unmatched products
|
||||
- [OMS Feature Plan](oms-features.md) — Order management system roadmap
|
||||
- [VAT Invoicing](vat-invoicing.md) — VAT decision tree and invoice generation
|
||||
- [Stock Integration](stock-integration.md) — Order-inventory synchronization
|
||||
662
app/modules/orders/docs/oms-features.md
Normal file
662
app/modules/orders/docs/oms-features.md
Normal file
@@ -0,0 +1,662 @@
|
||||
# OMS Feature Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Transform Orion into a **"Lightweight OMS for Letzshop Sellers"** by building the missing features that justify the tier pricing structure.
|
||||
|
||||
**Goal:** Ship Essential tier quickly, then build Professional differentiators, then Business features.
|
||||
|
||||
## Design Decisions (Confirmed)
|
||||
|
||||
| Decision | Choice |
|
||||
|----------|--------|
|
||||
| Phase 1 scope | Invoicing + Tier Limits together |
|
||||
| PDF library | WeasyPrint (HTML/CSS to PDF) |
|
||||
| Invoice style | Simple & Clean (minimal design) |
|
||||
|
||||
---
|
||||
|
||||
## Current State Summary
|
||||
|
||||
### Already Production-Ready
|
||||
- Multi-tenant architecture (Merchant → Store hierarchy)
|
||||
- Letzshop order sync, confirmation, tracking
|
||||
- Inventory management with locations and reservations
|
||||
- Unified Order model (direct + marketplace)
|
||||
- Customer model with pre-calculated stats (total_orders, total_spent)
|
||||
- Team management + RBAC
|
||||
- CSV export patterns (products)
|
||||
|
||||
### Needs to be Built
|
||||
| Feature | Tier Impact | Priority |
|
||||
|---------|-------------|----------|
|
||||
| Basic LU Invoice (PDF) | Essential | P0 |
|
||||
| Tier limits enforcement | Essential | P0 |
|
||||
| Store VAT Settings | Professional | P1 |
|
||||
| EU VAT Invoice | Professional | P1 |
|
||||
| Incoming Stock / PO | Professional | P1 |
|
||||
| Customer CSV Export | Professional | P1 |
|
||||
| Multi-store view | Business | P2 |
|
||||
| Accounting export | Business | P2 |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Essential Tier (Target: 1 week)
|
||||
|
||||
**Goal:** Launch Essential (€49) with basic invoicing and tier enforcement.
|
||||
|
||||
### Step 1.1: Store Invoice Settings (1 day)
|
||||
|
||||
**Create model for store billing details:**
|
||||
|
||||
```
|
||||
models/database/store_invoice_settings.py
|
||||
```
|
||||
|
||||
Fields:
|
||||
- `store_id` (FK, unique - one-to-one)
|
||||
- `merchant_name` (legal name for invoices)
|
||||
- `merchant_address`, `merchant_city`, `merchant_postal_code`, `merchant_country`
|
||||
- `vat_number` (e.g., "LU12345678")
|
||||
- `invoice_prefix` (default "INV")
|
||||
- `invoice_next_number` (auto-increment)
|
||||
- `payment_terms` (optional text)
|
||||
- `bank_details` (optional IBAN etc.)
|
||||
- `footer_text` (optional)
|
||||
|
||||
**Pattern to follow:** `models/database/letzshop.py` (StoreLetzshopCredentials)
|
||||
|
||||
**Files to create/modify:**
|
||||
- `models/database/store_invoice_settings.py` (new)
|
||||
- `models/database/__init__.py` (add import)
|
||||
- `models/database/store.py` (add relationship)
|
||||
- `models/schema/invoice.py` (new - Pydantic schemas)
|
||||
- `alembic/versions/xxx_add_store_invoice_settings.py` (migration)
|
||||
|
||||
---
|
||||
|
||||
### Step 1.2: Basic Invoice Model (0.5 day)
|
||||
|
||||
**Create invoice storage:**
|
||||
|
||||
```
|
||||
models/database/invoice.py
|
||||
```
|
||||
|
||||
Fields:
|
||||
- `id`, `store_id` (FK)
|
||||
- `order_id` (FK, nullable - for manual invoices later)
|
||||
- `invoice_number` (unique per store)
|
||||
- `invoice_date`
|
||||
- `seller_details` (JSONB snapshot)
|
||||
- `buyer_details` (JSONB snapshot)
|
||||
- `line_items` (JSONB snapshot)
|
||||
- `subtotal_cents`, `vat_rate`, `vat_amount_cents`, `total_cents`
|
||||
- `currency` (default EUR)
|
||||
- `status` (draft, issued, paid, cancelled)
|
||||
- `pdf_generated_at`, `pdf_path` (optional)
|
||||
|
||||
**Files to create/modify:**
|
||||
- `models/database/invoice.py` (new)
|
||||
- `models/database/__init__.py` (add import)
|
||||
- `alembic/versions/xxx_add_invoices_table.py` (migration)
|
||||
|
||||
---
|
||||
|
||||
### Step 1.3: Invoice Service - Basic LU Only (1 day)
|
||||
|
||||
**Create service for invoice generation:**
|
||||
|
||||
```
|
||||
app/services/invoice_service.py
|
||||
```
|
||||
|
||||
Methods:
|
||||
- `create_invoice_from_order(order_id, store_id)` - Generate invoice from order
|
||||
- `get_invoice(invoice_id, store_id)` - Retrieve invoice
|
||||
- `list_invoices(store_id, skip, limit)` - List store invoices
|
||||
- `_generate_invoice_number(settings)` - Auto-increment number
|
||||
- `_snapshot_seller(settings)` - Capture store details
|
||||
- `_snapshot_buyer(order)` - Capture customer details
|
||||
- `_calculate_totals(order)` - Calculate with LU VAT (17%)
|
||||
|
||||
**For Essential tier:** Fixed 17% Luxembourg VAT only. EU VAT comes in Professional.
|
||||
|
||||
**Files to create:**
|
||||
- `app/services/invoice_service.py` (new)
|
||||
|
||||
---
|
||||
|
||||
### Step 1.4: PDF Generation (1.5 days)
|
||||
|
||||
**Add WeasyPrint dependency and create PDF service:**
|
||||
|
||||
```
|
||||
app/services/invoice_pdf_service.py
|
||||
```
|
||||
|
||||
Methods:
|
||||
- `generate_pdf(invoice)` - Returns PDF bytes
|
||||
- `_render_html(invoice)` - Jinja2 template rendering
|
||||
|
||||
**Template:**
|
||||
```
|
||||
app/templates/invoices/invoice.html
|
||||
```
|
||||
|
||||
Simple, clean invoice layout:
|
||||
- Seller details (top left)
|
||||
- Buyer details (top right)
|
||||
- Invoice number + date
|
||||
- Line items table
|
||||
- Totals with VAT breakdown
|
||||
- Footer (payment terms, bank details)
|
||||
|
||||
**Files to create/modify:**
|
||||
- `requirements.txt` (add weasyprint)
|
||||
- `app/services/invoice_pdf_service.py` (new)
|
||||
- `app/templates/invoices/invoice.html` (new)
|
||||
- `app/templates/invoices/invoice.css` (new, optional)
|
||||
|
||||
---
|
||||
|
||||
### Step 1.5: Invoice API Endpoints (0.5 day)
|
||||
|
||||
**Create store invoice endpoints:**
|
||||
|
||||
```
|
||||
app/api/v1/store/invoices.py
|
||||
```
|
||||
|
||||
Endpoints:
|
||||
- `POST /orders/{order_id}/invoice` - Generate invoice for order
|
||||
- `GET /invoices` - List invoices
|
||||
- `GET /invoices/{invoice_id}` - Get invoice details
|
||||
- `GET /invoices/{invoice_id}/pdf` - Download PDF
|
||||
|
||||
**Files to create/modify:**
|
||||
- `app/api/v1/store/invoices.py` (new)
|
||||
- `app/api/v1/store/__init__.py` (add router)
|
||||
|
||||
---
|
||||
|
||||
### Step 1.6: Invoice Settings UI (0.5 day)
|
||||
|
||||
**Add invoice settings to store settings page:**
|
||||
|
||||
Modify existing store settings template to add "Invoice Settings" section:
|
||||
- Merchant name, address fields
|
||||
- VAT number
|
||||
- Invoice prefix
|
||||
- Payment terms
|
||||
- Bank details
|
||||
|
||||
**Files to modify:**
|
||||
- `app/templates/store/settings.html` (add section)
|
||||
- `static/store/js/settings.js` (add handlers)
|
||||
- `app/api/v1/store/settings.py` (add endpoints if needed)
|
||||
|
||||
---
|
||||
|
||||
### Step 1.7: Order Detail - Invoice Button (0.5 day)
|
||||
|
||||
**Add "Generate Invoice" / "Download Invoice" button to order detail:**
|
||||
|
||||
- If no invoice: Show "Generate Invoice" button
|
||||
- If invoice exists: Show "Download Invoice" link
|
||||
|
||||
**Files to modify:**
|
||||
- `app/templates/store/order-detail.html` (add button)
|
||||
- `static/store/js/order-detail.js` (add handler)
|
||||
|
||||
---
|
||||
|
||||
### Step 1.8: Tier Limits Enforcement (1 day)
|
||||
|
||||
**Create tier/subscription model:**
|
||||
|
||||
```
|
||||
models/database/store_subscription.py
|
||||
```
|
||||
|
||||
Fields:
|
||||
- `store_id` (FK, unique)
|
||||
- `tier` (essential, professional, business)
|
||||
- `orders_this_month` (counter, reset monthly)
|
||||
- `period_start`, `period_end`
|
||||
- `is_active`
|
||||
|
||||
**Create limits service:**
|
||||
|
||||
```
|
||||
app/services/tier_limits_service.py
|
||||
```
|
||||
|
||||
Methods:
|
||||
- `check_order_limit(store_id)` - Returns (allowed: bool, remaining: int)
|
||||
- `increment_order_count(store_id)` - Called when order synced
|
||||
- `get_tier_limits(tier)` - Returns limit config
|
||||
- `reset_monthly_counters()` - Cron job
|
||||
|
||||
**Tier limits:**
|
||||
| Tier | Orders/month | Products |
|
||||
|------|--------------|----------|
|
||||
| Essential | 100 | 200 |
|
||||
| Professional | 500 | Unlimited |
|
||||
| Business | Unlimited | Unlimited |
|
||||
|
||||
**Integration points:**
|
||||
- `order_service.py` - Check limit before creating order
|
||||
- Letzshop sync - Check limit before importing
|
||||
|
||||
**Files to create/modify:**
|
||||
- `models/database/store_subscription.py` (new)
|
||||
- `app/services/tier_limits_service.py` (new)
|
||||
- `app/services/order_service.py` (add limit check)
|
||||
- `app/services/letzshop/order_service.py` (add limit check)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Professional Tier (Target: 2 weeks)
|
||||
|
||||
**Goal:** Build the differentiating features that justify €99/month.
|
||||
|
||||
### Step 2.1: EU VAT Rates Table (0.5 day)
|
||||
|
||||
**Create VAT rates reference table:**
|
||||
|
||||
```
|
||||
models/database/eu_vat_rates.py
|
||||
```
|
||||
|
||||
Fields:
|
||||
- `country_code` (LU, DE, FR, etc.)
|
||||
- `country_name`
|
||||
- `standard_rate` (decimal)
|
||||
- `reduced_rate_1`, `reduced_rate_2` (optional)
|
||||
- `effective_from`, `effective_until`
|
||||
|
||||
**Seed with current EU rates (27 countries).**
|
||||
|
||||
**Files to create:**
|
||||
- `models/database/eu_vat_rates.py` (new)
|
||||
- `alembic/versions/xxx_add_eu_vat_rates.py` (migration + seed)
|
||||
|
||||
---
|
||||
|
||||
### Step 2.2: Enhanced Store VAT Settings (0.5 day)
|
||||
|
||||
**Add OSS fields to StoreInvoiceSettings:**
|
||||
|
||||
- `is_oss_registered` (boolean)
|
||||
- `oss_registration_country` (if different from merchant country)
|
||||
|
||||
**Files to modify:**
|
||||
- `models/database/store_invoice_settings.py` (add fields)
|
||||
- `alembic/versions/xxx_add_oss_fields.py` (migration)
|
||||
|
||||
---
|
||||
|
||||
### Step 2.3: VAT Service (1 day)
|
||||
|
||||
**Create VAT calculation service:**
|
||||
|
||||
```
|
||||
app/services/vat_service.py
|
||||
```
|
||||
|
||||
Methods:
|
||||
- `get_vat_rate(country_code, as_of_date)` - Lookup rate
|
||||
- `determine_vat_regime(seller_country, buyer_country, buyer_vat_number, is_oss)` - Returns (regime, rate)
|
||||
- `validate_vat_number(vat_number)` - Format check (VIES integration later)
|
||||
|
||||
**VAT Decision Logic:**
|
||||
1. B2B with valid VAT number → Reverse charge (0%)
|
||||
2. Domestic sale → Domestic VAT
|
||||
3. Cross-border + OSS registered → Destination VAT
|
||||
4. Cross-border + under threshold → Origin VAT
|
||||
|
||||
**Files to create:**
|
||||
- `app/services/vat_service.py` (new)
|
||||
|
||||
---
|
||||
|
||||
### Step 2.4: Enhanced Invoice Service (1 day)
|
||||
|
||||
**Upgrade invoice service for EU VAT:**
|
||||
|
||||
- Add `vat_regime` field to invoice (domestic, oss, reverse_charge, origin)
|
||||
- Add `destination_country` field
|
||||
- Use VATService to calculate correct rate
|
||||
- Update invoice template for regime-specific text
|
||||
|
||||
**Files to modify:**
|
||||
- `models/database/invoice.py` (add fields)
|
||||
- `app/services/invoice_service.py` (use VATService)
|
||||
- `app/templates/invoices/invoice.html` (add regime text)
|
||||
- `alembic/versions/xxx_add_vat_regime_to_invoices.py`
|
||||
|
||||
---
|
||||
|
||||
### Step 2.5: Purchase Order Model (1 day)
|
||||
|
||||
**Create purchase order tracking:**
|
||||
|
||||
```
|
||||
models/database/purchase_order.py
|
||||
```
|
||||
|
||||
**PurchaseOrder:**
|
||||
- `id`, `store_id` (FK)
|
||||
- `po_number` (auto-generated)
|
||||
- `supplier_name` (free text for now)
|
||||
- `status` (draft, ordered, partial, received, cancelled)
|
||||
- `order_date`, `expected_date`
|
||||
- `notes`
|
||||
|
||||
**PurchaseOrderItem:**
|
||||
- `purchase_order_id` (FK)
|
||||
- `product_id` (FK)
|
||||
- `quantity_ordered`
|
||||
- `quantity_received`
|
||||
- `unit_cost_cents` (optional)
|
||||
|
||||
**Files to create:**
|
||||
- `models/database/purchase_order.py` (new)
|
||||
- `models/database/__init__.py` (add import)
|
||||
- `models/schema/purchase_order.py` (new)
|
||||
- `alembic/versions/xxx_add_purchase_orders.py`
|
||||
|
||||
---
|
||||
|
||||
### Step 2.6: Purchase Order Service (1 day)
|
||||
|
||||
**Create PO management service:**
|
||||
|
||||
```
|
||||
app/services/purchase_order_service.py
|
||||
```
|
||||
|
||||
Methods:
|
||||
- `create_purchase_order(store_id, data)` - Create PO
|
||||
- `add_item(po_id, product_id, quantity)` - Add line item
|
||||
- `receive_items(po_id, items)` - Mark items received, update inventory
|
||||
- `get_incoming_stock(store_id)` - Summary of pending stock
|
||||
- `list_purchase_orders(store_id, status, skip, limit)`
|
||||
|
||||
**Integration:** When items received → call `inventory_service.adjust_inventory()`
|
||||
|
||||
**Files to create:**
|
||||
- `app/services/purchase_order_service.py` (new)
|
||||
|
||||
---
|
||||
|
||||
### Step 2.7: Purchase Order UI (1.5 days)
|
||||
|
||||
**Create PO management page:**
|
||||
|
||||
```
|
||||
app/templates/store/purchase-orders.html
|
||||
```
|
||||
|
||||
Features:
|
||||
- List POs with status
|
||||
- Create new PO (select products, quantities, expected date)
|
||||
- Receive items (partial or full)
|
||||
- View incoming stock summary
|
||||
|
||||
**Inventory page enhancement:**
|
||||
- Show "On Order" column in inventory list
|
||||
- Query: SUM of quantity_ordered - quantity_received for pending POs
|
||||
|
||||
**Files to create/modify:**
|
||||
- `app/templates/store/purchase-orders.html` (new)
|
||||
- `static/store/js/purchase-orders.js` (new)
|
||||
- `app/api/v1/store/purchase_orders.py` (new endpoints)
|
||||
- `app/routes/store_pages.py` (add route)
|
||||
- `app/templates/store/partials/sidebar.html` (add menu item)
|
||||
- `app/templates/store/inventory.html` (add On Order column)
|
||||
|
||||
---
|
||||
|
||||
### Step 2.8: Customer Export Service (1 day)
|
||||
|
||||
**Create customer export functionality:**
|
||||
|
||||
```
|
||||
app/services/customer_export_service.py
|
||||
```
|
||||
|
||||
Methods:
|
||||
- `export_customers_csv(store_id, filters)` - Returns CSV string
|
||||
|
||||
**CSV Columns:**
|
||||
- email, first_name, last_name, phone
|
||||
- customer_number
|
||||
- total_orders, total_spent, avg_order_value
|
||||
- first_order_date, last_order_date
|
||||
- preferred_language
|
||||
- marketing_consent
|
||||
- tags (if we add tagging)
|
||||
|
||||
**Files to create:**
|
||||
- `app/services/customer_export_service.py` (new)
|
||||
|
||||
---
|
||||
|
||||
### Step 2.9: Customer Export API + UI (0.5 day)
|
||||
|
||||
**Add export endpoint:**
|
||||
|
||||
```
|
||||
GET /api/v1/store/customers/export?format=csv
|
||||
```
|
||||
|
||||
**Add export button to customers page:**
|
||||
- "Export to CSV" button
|
||||
- Downloads file directly
|
||||
|
||||
**Files to modify:**
|
||||
- `app/api/v1/store/customers.py` (add export endpoint)
|
||||
- `app/templates/store/customers.html` (add button)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Business Tier (Target: 1-2 weeks)
|
||||
|
||||
**Goal:** Build features for teams and high-volume operations.
|
||||
|
||||
### Step 3.1: Multi-Store Consolidated View (2 days)
|
||||
|
||||
**For merchants with multiple Letzshop accounts:**
|
||||
|
||||
**New page:**
|
||||
```
|
||||
app/templates/store/multi-store-dashboard.html
|
||||
```
|
||||
|
||||
Features:
|
||||
- See all store accounts under same merchant
|
||||
- Consolidated order count, revenue
|
||||
- Switch between store contexts
|
||||
- Unified reporting
|
||||
|
||||
**Requires:** Merchant-level authentication context (already exists via Merchant → Store relationship)
|
||||
|
||||
**Files to create/modify:**
|
||||
- `app/templates/store/multi-store-dashboard.html` (new)
|
||||
- `app/services/multi_store_service.py` (new)
|
||||
- `app/api/v1/store/multi_store.py` (new)
|
||||
|
||||
---
|
||||
|
||||
### Step 3.2: Accounting Export (1 day)
|
||||
|
||||
**Export invoices in accounting-friendly formats:**
|
||||
|
||||
```
|
||||
app/services/accounting_export_service.py
|
||||
```
|
||||
|
||||
Methods:
|
||||
- `export_invoices_csv(store_id, date_from, date_to)` - Simple CSV
|
||||
- `export_invoices_xml(store_id, date_from, date_to)` - For accounting software
|
||||
|
||||
**CSV format for accountants:**
|
||||
- invoice_number, invoice_date
|
||||
- customer_name, customer_vat
|
||||
- subtotal, vat_rate, vat_amount, total
|
||||
- currency, status
|
||||
|
||||
**Files to create:**
|
||||
- `app/services/accounting_export_service.py` (new)
|
||||
- `app/api/v1/store/accounting.py` (new endpoints)
|
||||
|
||||
---
|
||||
|
||||
### Step 3.3: API Access Documentation (1 day)
|
||||
|
||||
**If not already documented, create API documentation page:**
|
||||
|
||||
- Document existing store API endpoints
|
||||
- Add rate limiting for API tier
|
||||
- Generate API keys for stores
|
||||
|
||||
**Files to create/modify:**
|
||||
- `docs/api/store-api.md` (documentation)
|
||||
- `app/services/api_key_service.py` (if needed)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order Summary
|
||||
|
||||
### Week 1: Essential Tier
|
||||
| Day | Task | Deliverable |
|
||||
|-----|------|-------------|
|
||||
| 1 | Step 1.1 | Store Invoice Settings model |
|
||||
| 1 | Step 1.2 | Invoice model |
|
||||
| 2 | Step 1.3 | Invoice Service (LU only) |
|
||||
| 3-4 | Step 1.4 | PDF Generation |
|
||||
| 4 | Step 1.5 | Invoice API |
|
||||
| 5 | Step 1.6 | Invoice Settings UI |
|
||||
| 5 | Step 1.7 | Order Detail button |
|
||||
|
||||
### Week 2: Tier Limits + EU VAT Start
|
||||
| Day | Task | Deliverable |
|
||||
|-----|------|-------------|
|
||||
| 1 | Step 1.8 | Tier limits enforcement |
|
||||
| 2 | Step 2.1 | EU VAT rates table |
|
||||
| 2 | Step 2.2 | OSS fields |
|
||||
| 3 | Step 2.3 | VAT Service |
|
||||
| 4 | Step 2.4 | Enhanced Invoice Service |
|
||||
| 5 | Testing | End-to-end invoice testing |
|
||||
|
||||
### Week 3: Purchase Orders + Customer Export
|
||||
| Day | Task | Deliverable |
|
||||
|-----|------|-------------|
|
||||
| 1 | Step 2.5 | Purchase Order model |
|
||||
| 2 | Step 2.6 | Purchase Order service |
|
||||
| 3-4 | Step 2.7 | Purchase Order UI |
|
||||
| 5 | Step 2.8-2.9 | Customer Export |
|
||||
|
||||
### Week 4: Business Tier
|
||||
| Day | Task | Deliverable |
|
||||
|-----|------|-------------|
|
||||
| 1-2 | Step 3.1 | Multi-store view |
|
||||
| 3 | Step 3.2 | Accounting export |
|
||||
| 4 | Step 3.3 | API documentation |
|
||||
| 5 | Testing + Polish | Full testing |
|
||||
|
||||
---
|
||||
|
||||
## Key Files Reference
|
||||
|
||||
### Models to Create
|
||||
- `models/database/store_invoice_settings.py`
|
||||
- `models/database/invoice.py`
|
||||
- `models/database/eu_vat_rates.py`
|
||||
- `models/database/store_subscription.py`
|
||||
- `models/database/purchase_order.py`
|
||||
|
||||
### Services to Create
|
||||
- `app/services/invoice_service.py`
|
||||
- `app/services/invoice_pdf_service.py`
|
||||
- `app/services/vat_service.py`
|
||||
- `app/services/tier_limits_service.py`
|
||||
- `app/services/purchase_order_service.py`
|
||||
- `app/services/customer_export_service.py`
|
||||
- `app/services/accounting_export_service.py`
|
||||
|
||||
### Templates to Create
|
||||
- `app/templates/invoices/invoice.html`
|
||||
- `app/templates/store/purchase-orders.html`
|
||||
|
||||
### Existing Files to Modify
|
||||
- `models/database/__init__.py`
|
||||
- `models/database/store.py`
|
||||
- `app/services/order_service.py`
|
||||
- `app/templates/store/settings.html`
|
||||
- `app/templates/store/order-detail.html`
|
||||
- `app/templates/store/inventory.html`
|
||||
- `app/templates/store/customers.html`
|
||||
- `requirements.txt`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies to Add
|
||||
|
||||
```
|
||||
# requirements.txt
|
||||
weasyprint>=60.0
|
||||
```
|
||||
|
||||
**Note:** WeasyPrint requires system dependencies:
|
||||
- `libpango-1.0-0`
|
||||
- `libpangocairo-1.0-0`
|
||||
- `libgdk-pixbuf2.0-0`
|
||||
|
||||
Add to Dockerfile if deploying via Docker.
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- `tests/unit/services/test_invoice_service.py`
|
||||
- `tests/unit/services/test_vat_service.py`
|
||||
- `tests/unit/services/test_tier_limits_service.py`
|
||||
- `tests/unit/services/test_purchase_order_service.py`
|
||||
|
||||
### Integration Tests
|
||||
- `tests/integration/api/v1/store/test_invoices.py`
|
||||
- `tests/integration/api/v1/store/test_purchase_orders.py`
|
||||
|
||||
### Manual Testing
|
||||
- Generate invoice for LU customer
|
||||
- Generate invoice for DE customer (OSS)
|
||||
- Generate invoice for B2B with VAT number (reverse charge)
|
||||
- Create PO, receive items, verify inventory update
|
||||
- Export customers CSV, import to Mailchimp
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Essential Tier Ready When:
|
||||
- [ ] Can generate PDF invoice from order (LU VAT)
|
||||
- [ ] Invoice settings page works
|
||||
- [ ] Order detail shows invoice button
|
||||
- [ ] Tier limits enforced on order sync
|
||||
|
||||
### Professional Tier Ready When:
|
||||
- [ ] EU VAT calculated correctly by destination
|
||||
- [ ] OSS regime supported
|
||||
- [ ] Reverse charge for B2B supported
|
||||
- [ ] Purchase orders can be created and received
|
||||
- [ ] Incoming stock shows in inventory
|
||||
- [ ] Customer export to CSV works
|
||||
|
||||
### Business Tier Ready When:
|
||||
- [ ] Multi-store dashboard works
|
||||
- [ ] Accounting export works
|
||||
- [ ] API access documented
|
||||
371
app/modules/orders/docs/stock-integration.md
Normal file
371
app/modules/orders/docs/stock-integration.md
Normal file
@@ -0,0 +1,371 @@
|
||||
# Stock Management Integration
|
||||
|
||||
**Created:** January 1, 2026
|
||||
**Status:** Implemented
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the automatic inventory synchronization between orders and stock levels. When order status changes, inventory is automatically updated to maintain accurate stock counts.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Services Involved
|
||||
|
||||
```
|
||||
OrderService OrderInventoryService
|
||||
│ │
|
||||
├─ update_order_status() ──────────► handle_status_change()
|
||||
│ │
|
||||
│ ├─► reserve_for_order()
|
||||
│ ├─► fulfill_order()
|
||||
│ └─► release_order_reservation()
|
||||
│ │
|
||||
│ ▼
|
||||
│ InventoryService
|
||||
│ │
|
||||
│ ├─► reserve_inventory()
|
||||
│ ├─► fulfill_reservation()
|
||||
│ └─► release_reservation()
|
||||
```
|
||||
|
||||
### Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `app/services/order_inventory_service.py` | Orchestrates order-inventory operations |
|
||||
| `app/services/order_service.py` | Calls inventory hooks on status change |
|
||||
| `app/services/inventory_service.py` | Low-level inventory operations |
|
||||
|
||||
## Status Change Inventory Actions
|
||||
|
||||
| Status Transition | Inventory Action | Description |
|
||||
|-------------------|------------------|-------------|
|
||||
| Any → `processing` | Reserve | Reserves stock for order items |
|
||||
| Any → `shipped` | Fulfill | Deducts from stock and releases reservation |
|
||||
| Any → `cancelled` | Release | Returns reserved stock to available |
|
||||
|
||||
## Inventory Operations
|
||||
|
||||
### Reserve Inventory
|
||||
|
||||
When an order status changes to `processing`:
|
||||
|
||||
1. For each order item:
|
||||
- Find inventory record with available quantity
|
||||
- Increase `reserved_quantity` by item quantity
|
||||
- Log the reservation
|
||||
|
||||
2. Placeholder products (unmatched Letzshop items) are skipped
|
||||
|
||||
### Fulfill Inventory
|
||||
|
||||
When an order status changes to `shipped`:
|
||||
|
||||
1. For each order item:
|
||||
- Decrease `quantity` by item quantity (stock consumed)
|
||||
- Decrease `reserved_quantity` accordingly
|
||||
- Log the fulfillment
|
||||
|
||||
### Release Reservation
|
||||
|
||||
When an order is `cancelled`:
|
||||
|
||||
1. For each order item:
|
||||
- Decrease `reserved_quantity` (stock becomes available again)
|
||||
- Total `quantity` remains unchanged
|
||||
- Log the release
|
||||
|
||||
## Error Handling
|
||||
|
||||
Inventory operations use **soft failure** - if inventory cannot be updated:
|
||||
|
||||
1. Warning is logged
|
||||
2. Order status update continues
|
||||
3. Inventory can be manually adjusted
|
||||
|
||||
This ensures orders are never blocked by inventory issues while providing visibility into any problems.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
### Placeholder Products
|
||||
|
||||
Letzshop orders may contain unmatched GTINs that map to placeholder products. These are identified by:
|
||||
- GTIN `0000000000000`
|
||||
- Product linked to placeholder MarketplaceProduct
|
||||
|
||||
Inventory operations skip placeholder products since they have no real stock.
|
||||
|
||||
### Missing Inventory
|
||||
|
||||
If a product has no inventory record:
|
||||
- Operation is skipped with `skip_missing=True`
|
||||
- Item is logged in `skipped_items` list
|
||||
- No error is raised
|
||||
|
||||
### Multi-Location Inventory
|
||||
|
||||
The service finds the first location with available stock:
|
||||
```python
|
||||
def _find_inventory_location(db, product_id, store_id):
|
||||
return (
|
||||
db.query(Inventory)
|
||||
.filter(
|
||||
Inventory.product_id == product_id,
|
||||
Inventory.store_id == store_id,
|
||||
Inventory.quantity > Inventory.reserved_quantity,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
```
|
||||
|
||||
## Usage Example
|
||||
|
||||
### Automatic (Via Order Status Update)
|
||||
|
||||
```python
|
||||
from app.services.order_service import order_service
|
||||
from models.schema.order import OrderUpdate
|
||||
|
||||
# Update order status - inventory is handled automatically
|
||||
order = order_service.update_order_status(
|
||||
db=db,
|
||||
store_id=store_id,
|
||||
order_id=order_id,
|
||||
order_update=OrderUpdate(status="processing")
|
||||
)
|
||||
# Inventory is now reserved for this order
|
||||
```
|
||||
|
||||
### Direct (Manual Operations)
|
||||
|
||||
```python
|
||||
from app.services.order_inventory_service import order_inventory_service
|
||||
|
||||
# Reserve inventory for an order
|
||||
result = order_inventory_service.reserve_for_order(
|
||||
db=db,
|
||||
store_id=store_id,
|
||||
order_id=order_id,
|
||||
skip_missing=True
|
||||
)
|
||||
print(f"Reserved: {result['reserved_count']}, Skipped: {len(result['skipped_items'])}")
|
||||
|
||||
# Fulfill when shipped
|
||||
result = order_inventory_service.fulfill_order(
|
||||
db=db,
|
||||
store_id=store_id,
|
||||
order_id=order_id
|
||||
)
|
||||
|
||||
# Release if cancelled
|
||||
result = order_inventory_service.release_order_reservation(
|
||||
db=db,
|
||||
store_id=store_id,
|
||||
order_id=order_id
|
||||
)
|
||||
```
|
||||
|
||||
## Inventory Model
|
||||
|
||||
```python
|
||||
class Inventory:
|
||||
quantity: int # Total stock
|
||||
reserved_quantity: int # Reserved for pending orders
|
||||
|
||||
@property
|
||||
def available_quantity(self):
|
||||
return self.quantity - self.reserved_quantity
|
||||
```
|
||||
|
||||
## Logging
|
||||
|
||||
All inventory operations are logged:
|
||||
|
||||
```
|
||||
INFO: Reserved 2 units of product 123 for order ORD-1-20260101-ABC123
|
||||
INFO: Order ORD-1-20260101-ABC123: reserved 3 items, skipped 1
|
||||
INFO: Fulfilled 2 units of product 123 for order ORD-1-20260101-ABC123
|
||||
WARNING: Order ORD-1-20260101-ABC123 inventory operation failed: No inventory found
|
||||
```
|
||||
|
||||
## Audit Trail (Phase 2)
|
||||
|
||||
All inventory operations are logged to the `inventory_transactions` table.
|
||||
|
||||
### Transaction Types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `reserve` | Stock reserved for order |
|
||||
| `fulfill` | Reserved stock consumed (shipped) |
|
||||
| `release` | Reserved stock released (cancelled) |
|
||||
| `adjust` | Manual adjustment (+/-) |
|
||||
| `set` | Set to exact quantity |
|
||||
| `import` | Initial import/sync |
|
||||
| `return` | Stock returned from customer |
|
||||
|
||||
### Transaction Record
|
||||
|
||||
```python
|
||||
class InventoryTransaction:
|
||||
id: int
|
||||
store_id: int
|
||||
product_id: int
|
||||
inventory_id: int | None
|
||||
transaction_type: TransactionType
|
||||
quantity_change: int # Positive = add, negative = remove
|
||||
quantity_after: int # Snapshot after transaction
|
||||
reserved_after: int # Snapshot after transaction
|
||||
location: str | None
|
||||
warehouse: str | None
|
||||
order_id: int | None # Link to order if applicable
|
||||
order_number: str | None
|
||||
reason: str | None # Human-readable reason
|
||||
created_by: str | None # User/system identifier
|
||||
created_at: datetime
|
||||
```
|
||||
|
||||
### Example Transaction Query
|
||||
|
||||
```python
|
||||
from models.database import InventoryTransaction, TransactionType
|
||||
|
||||
# Get all transactions for an order
|
||||
transactions = db.query(InventoryTransaction).filter(
|
||||
InventoryTransaction.order_id == order_id
|
||||
).order_by(InventoryTransaction.created_at).all()
|
||||
|
||||
# Get recent stock changes for a product
|
||||
recent = db.query(InventoryTransaction).filter(
|
||||
InventoryTransaction.product_id == product_id,
|
||||
InventoryTransaction.store_id == store_id,
|
||||
).order_by(InventoryTransaction.created_at.desc()).limit(10).all()
|
||||
```
|
||||
|
||||
## Partial Shipments (Phase 3)
|
||||
|
||||
Orders can be partially shipped, allowing stores to ship items as they become available.
|
||||
|
||||
### Status Flow
|
||||
|
||||
```
|
||||
pending → processing → partially_shipped → shipped → delivered
|
||||
↘ ↗
|
||||
→ shipped (if all items shipped at once)
|
||||
```
|
||||
|
||||
### OrderItem Tracking
|
||||
|
||||
Each order item has a `shipped_quantity` field:
|
||||
|
||||
```python
|
||||
class OrderItem:
|
||||
quantity: int # Total ordered
|
||||
shipped_quantity: int # Units shipped so far
|
||||
|
||||
@property
|
||||
def remaining_quantity(self):
|
||||
return self.quantity - self.shipped_quantity
|
||||
|
||||
@property
|
||||
def is_fully_shipped(self):
|
||||
return self.shipped_quantity >= self.quantity
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
#### Get Shipment Status
|
||||
|
||||
```http
|
||||
GET /api/v1/store/orders/{order_id}/shipment-status
|
||||
```
|
||||
|
||||
Returns item-level shipment status:
|
||||
```json
|
||||
{
|
||||
"order_id": 123,
|
||||
"order_number": "ORD-1-20260101-ABC123",
|
||||
"order_status": "partially_shipped",
|
||||
"is_fully_shipped": false,
|
||||
"is_partially_shipped": true,
|
||||
"shipped_item_count": 1,
|
||||
"total_item_count": 3,
|
||||
"total_shipped_units": 2,
|
||||
"total_ordered_units": 5,
|
||||
"items": [
|
||||
{
|
||||
"item_id": 1,
|
||||
"product_name": "Widget A",
|
||||
"quantity": 2,
|
||||
"shipped_quantity": 2,
|
||||
"remaining_quantity": 0,
|
||||
"is_fully_shipped": true
|
||||
},
|
||||
{
|
||||
"item_id": 2,
|
||||
"product_name": "Widget B",
|
||||
"quantity": 3,
|
||||
"shipped_quantity": 0,
|
||||
"remaining_quantity": 3,
|
||||
"is_fully_shipped": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Ship Individual Item
|
||||
|
||||
```http
|
||||
POST /api/v1/store/orders/{order_id}/items/{item_id}/ship
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"quantity": 2 // Optional - defaults to remaining quantity
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"order_id": 123,
|
||||
"item_id": 1,
|
||||
"fulfilled_quantity": 2,
|
||||
"shipped_quantity": 2,
|
||||
"remaining_quantity": 0,
|
||||
"is_fully_shipped": true
|
||||
}
|
||||
```
|
||||
|
||||
### Automatic Status Updates
|
||||
|
||||
When shipping items:
|
||||
1. If some items are shipped → status becomes `partially_shipped`
|
||||
2. If all items are fully shipped → status becomes `shipped`
|
||||
|
||||
### Service Usage
|
||||
|
||||
```python
|
||||
from app.services.order_inventory_service import order_inventory_service
|
||||
|
||||
# Ship partial quantity of an item
|
||||
result = order_inventory_service.fulfill_item(
|
||||
db=db,
|
||||
store_id=store_id,
|
||||
order_id=order_id,
|
||||
item_id=item_id,
|
||||
quantity=2, # Ship 2 units
|
||||
)
|
||||
|
||||
# Get shipment status
|
||||
status = order_inventory_service.get_shipment_status(
|
||||
db=db,
|
||||
store_id=store_id,
|
||||
order_id=order_id,
|
||||
)
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Multi-Location Selection** - Choose which location to draw from
|
||||
2. **Backorder Support** - Handle orders when stock is insufficient
|
||||
3. **Return Processing** - Increase stock when orders are returned
|
||||
275
app/modules/orders/docs/unified-order-view.md
Normal file
275
app/modules/orders/docs/unified-order-view.md
Normal file
@@ -0,0 +1,275 @@
|
||||
# Unified Order Schema Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the unified order schema that consolidates all order types (direct and marketplace) into a single `orders` table with snapshotted customer and address data.
|
||||
|
||||
## Design Decision: Option B - Single Unified Table
|
||||
|
||||
After analyzing the gap between internal orders and Letzshop orders, we chose **Option B: Full Import to Order Table** with the following key principles:
|
||||
|
||||
1. **Single `orders` table** for all channels (direct, letzshop, future marketplaces)
|
||||
2. **Customer/address snapshots** preserved at order time (not just FK references)
|
||||
3. **Products must exist** in catalog - GTIN lookup errors trigger investigation
|
||||
4. **Inactive customers** created for marketplace imports until they register on storefront
|
||||
5. **No separate `letzshop_orders` table** - eliminates sync issues
|
||||
|
||||
## Schema Design
|
||||
|
||||
### Order Table
|
||||
|
||||
The `orders` table now includes:
|
||||
|
||||
```
|
||||
orders
|
||||
├── Identity
|
||||
│ ├── id (PK)
|
||||
│ ├── store_id (FK → stores)
|
||||
│ ├── customer_id (FK → customers)
|
||||
│ └── order_number (unique)
|
||||
│
|
||||
├── Channel/Source
|
||||
│ ├── channel (direct | letzshop)
|
||||
│ ├── external_order_id
|
||||
│ ├── external_shipment_id
|
||||
│ ├── external_order_number
|
||||
│ └── external_data (JSON - raw marketplace data)
|
||||
│
|
||||
├── Status
|
||||
│ └── status (pending | processing | shipped | delivered | cancelled | refunded)
|
||||
│
|
||||
├── Financials
|
||||
│ ├── subtotal (nullable for marketplace)
|
||||
│ ├── tax_amount
|
||||
│ ├── shipping_amount
|
||||
│ ├── discount_amount
|
||||
│ ├── total_amount
|
||||
│ └── currency
|
||||
│
|
||||
├── Customer Snapshot
|
||||
│ ├── customer_first_name
|
||||
│ ├── customer_last_name
|
||||
│ ├── customer_email
|
||||
│ ├── customer_phone
|
||||
│ └── customer_locale
|
||||
│
|
||||
├── Shipping Address Snapshot
|
||||
│ ├── ship_first_name
|
||||
│ ├── ship_last_name
|
||||
│ ├── ship_company
|
||||
│ ├── ship_address_line_1
|
||||
│ ├── ship_address_line_2
|
||||
│ ├── ship_city
|
||||
│ ├── ship_postal_code
|
||||
│ └── ship_country_iso
|
||||
│
|
||||
├── Billing Address Snapshot
|
||||
│ ├── bill_first_name
|
||||
│ ├── bill_last_name
|
||||
│ ├── bill_company
|
||||
│ ├── bill_address_line_1
|
||||
│ ├── bill_address_line_2
|
||||
│ ├── bill_city
|
||||
│ ├── bill_postal_code
|
||||
│ └── bill_country_iso
|
||||
│
|
||||
├── Tracking
|
||||
│ ├── shipping_method
|
||||
│ ├── tracking_number
|
||||
│ └── tracking_provider
|
||||
│
|
||||
├── Notes
|
||||
│ ├── customer_notes
|
||||
│ └── internal_notes
|
||||
│
|
||||
└── Timestamps
|
||||
├── order_date (when customer placed order)
|
||||
├── confirmed_at
|
||||
├── shipped_at
|
||||
├── delivered_at
|
||||
├── cancelled_at
|
||||
├── created_at
|
||||
└── updated_at
|
||||
```
|
||||
|
||||
### OrderItem Table
|
||||
|
||||
The `order_items` table includes:
|
||||
|
||||
```
|
||||
order_items
|
||||
├── Identity
|
||||
│ ├── id (PK)
|
||||
│ ├── order_id (FK → orders)
|
||||
│ └── product_id (FK → products, NOT NULL)
|
||||
│
|
||||
├── Product Snapshot
|
||||
│ ├── product_name
|
||||
│ ├── product_sku
|
||||
│ ├── gtin
|
||||
│ └── gtin_type (ean13, upc, isbn, etc.)
|
||||
│
|
||||
├── Pricing
|
||||
│ ├── quantity
|
||||
│ ├── unit_price
|
||||
│ └── total_price
|
||||
│
|
||||
├── External References
|
||||
│ ├── external_item_id (Letzshop inventory unit ID)
|
||||
│ └── external_variant_id
|
||||
│
|
||||
├── Item State (marketplace confirmation)
|
||||
│ └── item_state (confirmed_available | confirmed_unavailable)
|
||||
│
|
||||
└── Inventory
|
||||
├── inventory_reserved
|
||||
└── inventory_fulfilled
|
||||
```
|
||||
|
||||
## Status Mapping
|
||||
|
||||
| Letzshop State | Order Status | Description |
|
||||
|----------------|--------------|-------------|
|
||||
| `unconfirmed` | `pending` | Order received, awaiting confirmation |
|
||||
| `confirmed` | `processing` | Items confirmed, being prepared |
|
||||
| `confirmed` + tracking | `shipped` | Shipped with tracking info |
|
||||
| `declined` | `cancelled` | All items declined |
|
||||
|
||||
## Customer Handling
|
||||
|
||||
When importing marketplace orders:
|
||||
|
||||
1. Look up customer by `(store_id, email)`
|
||||
2. If not found, create with `is_active=False`
|
||||
3. Customer becomes active when they register on storefront
|
||||
4. Customer info is always snapshotted in order (regardless of customer record)
|
||||
|
||||
This ensures:
|
||||
- Customer history is preserved even if customer info changes
|
||||
- Marketplace customers can later claim their order history
|
||||
- No data loss if customer record is modified
|
||||
|
||||
## Shipping Workflows
|
||||
|
||||
### Scenario 1: Letzshop Auto-Shipping
|
||||
|
||||
When using Letzshop's shipping service:
|
||||
|
||||
1. Order confirmed → `status = processing`
|
||||
2. Letzshop auto-creates shipment with their carrier
|
||||
3. Operator picks & packs
|
||||
4. Operator clicks "Retrieve Shipping Info"
|
||||
5. App fetches tracking from Letzshop API
|
||||
6. Order updated → `status = shipped`
|
||||
|
||||
### Scenario 2: Store Own Shipping
|
||||
|
||||
When store uses their own carrier:
|
||||
|
||||
1. Order confirmed → `status = processing`
|
||||
2. Operator picks & packs with own carrier
|
||||
3. Operator enters tracking info in app
|
||||
4. App sends tracking to Letzshop API
|
||||
5. Order updated → `status = shipped`
|
||||
|
||||
## Removed: LetzshopOrder Table
|
||||
|
||||
The `letzshop_orders` table has been removed. All data now goes directly into the unified `orders` table with `channel = 'letzshop'`.
|
||||
|
||||
### Migration of Existing References
|
||||
|
||||
- `LetzshopFulfillmentQueue.letzshop_order_id` → `order_id` (FK to `orders`)
|
||||
- `LetzshopSyncLog` - unchanged (no order reference)
|
||||
- `LetzshopHistoricalImportJob` - unchanged (no order reference)
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `models/database/order.py` | Complete rewrite with snapshots |
|
||||
| `models/database/letzshop.py` | Removed `LetzshopOrder`, updated `LetzshopFulfillmentQueue` |
|
||||
| `models/schema/order.py` | Updated schemas for new structure |
|
||||
| `models/schema/letzshop.py` | Updated schemas for unified Order model |
|
||||
| `app/services/order_service.py` | Unified service with `create_letzshop_order()` |
|
||||
| `app/services/letzshop/order_service.py` | Updated to use unified Order model |
|
||||
| `app/api/v1/admin/letzshop.py` | Updated endpoints for unified model |
|
||||
| `alembic/versions/c1d2e3f4a5b6_unified_order_schema.py` | Migration |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
All Letzshop order endpoints now use the unified Order model:
|
||||
|
||||
| Endpoint | Description |
|
||||
|----------|-------------|
|
||||
| `GET /admin/letzshop/stores/{id}/orders` | List orders with `channel='letzshop'` filter |
|
||||
| `GET /admin/letzshop/orders/{id}` | Get order detail with items |
|
||||
| `POST /admin/letzshop/stores/{id}/orders/{id}/confirm` | Confirm items via `external_item_id` |
|
||||
| `POST /admin/letzshop/stores/{id}/orders/{id}/reject` | Decline items via `external_item_id` |
|
||||
| `POST /admin/letzshop/stores/{id}/orders/{id}/items/{item_id}/confirm` | Confirm single item |
|
||||
| `POST /admin/letzshop/stores/{id}/orders/{id}/items/{item_id}/decline` | Decline single item |
|
||||
|
||||
## Order Number Format
|
||||
|
||||
| Channel | Format | Example |
|
||||
|---------|--------|---------|
|
||||
| Direct | `ORD-{store_id}-{date}-{random}` | `ORD-1-20251219-A1B2C3` |
|
||||
| Letzshop | `LS-{store_id}-{letzshop_order_number}` | `LS-1-ORD-123456` |
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Product Not Found by GTIN
|
||||
|
||||
When importing a Letzshop order, if a product cannot be found by its GTIN:
|
||||
|
||||
```python
|
||||
raise ValidationException(
|
||||
f"Product not found for GTIN {gtin}. "
|
||||
f"Please ensure the product catalog is in sync."
|
||||
)
|
||||
```
|
||||
|
||||
This is intentional - the Letzshop catalog is sourced from the store catalog, so missing products indicate a sync issue that must be investigated.
|
||||
|
||||
## Future Considerations
|
||||
|
||||
### Performance at Scale
|
||||
|
||||
As the orders table grows, consider:
|
||||
|
||||
1. **Partitioning** by `order_date` or `store_id`
|
||||
2. **Archiving** old orders to separate tables
|
||||
3. **Read replicas** for reporting queries
|
||||
4. **Materialized views** for dashboard statistics
|
||||
|
||||
### Additional Marketplaces
|
||||
|
||||
The schema supports additional channels:
|
||||
|
||||
```python
|
||||
channel = Column(String(50)) # direct, letzshop, amazon, ebay, etc.
|
||||
```
|
||||
|
||||
Each marketplace would use:
|
||||
- `external_order_id` - Marketplace order ID
|
||||
- `external_shipment_id` - Marketplace shipment ID
|
||||
- `external_order_number` - Display order number
|
||||
- `external_data` - Raw marketplace data (JSON)
|
||||
|
||||
## Implementation Status
|
||||
|
||||
- [x] Order model with snapshots
|
||||
- [x] OrderItem model with GTIN fields
|
||||
- [x] LetzshopFulfillmentQueue updated
|
||||
- [x] LetzshopOrder removed
|
||||
- [x] Database migration created
|
||||
- [x] Order schemas updated
|
||||
- [x] Unified order service created
|
||||
- [x] Letzshop order service updated
|
||||
- [x] Letzshop schemas updated
|
||||
- [x] API endpoints updated
|
||||
- [x] Frontend updated
|
||||
- [x] Orders tab template (status badges, filters, table)
|
||||
- [x] Order detail page (snapshots, items, tracking)
|
||||
- [x] JavaScript (API params, response handling)
|
||||
- [x] Tracking modal (tracking_provider field)
|
||||
- [x] Order items modal (items array, item_state)
|
||||
734
app/modules/orders/docs/vat-invoicing.md
Normal file
734
app/modules/orders/docs/vat-invoicing.md
Normal file
@@ -0,0 +1,734 @@
|
||||
# VAT Invoice Feature - Technical Specification
|
||||
|
||||
## Overview
|
||||
|
||||
Generate compliant PDF invoices with correct VAT calculation based on destination country, handling EU cross-border VAT rules including OSS (One-Stop-Shop) regime.
|
||||
|
||||
---
|
||||
|
||||
## EU VAT Rules Summary
|
||||
|
||||
### Standard VAT Rates by Country (2024)
|
||||
|
||||
| Country | Code | Standard Rate | Reduced |
|
||||
|---------|------|---------------|---------|
|
||||
| Luxembourg | LU | 17% | 8%, 3% |
|
||||
| Germany | DE | 19% | 7% |
|
||||
| France | FR | 20% | 10%, 5.5% |
|
||||
| Belgium | BE | 21% | 12%, 6% |
|
||||
| Netherlands | NL | 21% | 9% |
|
||||
| Austria | AT | 20% | 13%, 10% |
|
||||
| Italy | IT | 22% | 10%, 5%, 4% |
|
||||
| Spain | ES | 21% | 10%, 4% |
|
||||
| Portugal | PT | 23% | 13%, 6% |
|
||||
| Ireland | IE | 23% | 13.5%, 9% |
|
||||
| Poland | PL | 23% | 8%, 5% |
|
||||
| Czech Republic | CZ | 21% | 15%, 10% |
|
||||
| ... | ... | ... | ... |
|
||||
|
||||
### When to Apply Which VAT
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ VAT DECISION TREE │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Is buyer a business with valid VAT number? │
|
||||
│ ├── YES → Reverse charge (0% VAT, buyer accounts for it) │
|
||||
│ └── NO → Continue... │
|
||||
│ │
|
||||
│ Is destination same country as seller? │
|
||||
│ ├── YES → Apply domestic VAT (Luxembourg = 17%) │
|
||||
│ └── NO → Continue... │
|
||||
│ │
|
||||
│ Is seller registered for OSS? │
|
||||
│ ├── YES → Apply destination country VAT rate │
|
||||
│ └── NO → Continue... │
|
||||
│ │
|
||||
│ Has seller exceeded €10,000 EU threshold? │
|
||||
│ ├── YES → Must register OSS, apply destination VAT │
|
||||
│ └── NO → Apply origin country VAT (Luxembourg = 17%) │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
### New Tables
|
||||
|
||||
```sql
|
||||
-- VAT configuration per store
|
||||
CREATE TABLE store_vat_settings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
store_id UUID NOT NULL REFERENCES stores(id),
|
||||
|
||||
-- Merchant details for invoices
|
||||
merchant_name VARCHAR(255) NOT NULL,
|
||||
merchant_address TEXT NOT NULL,
|
||||
merchant_city VARCHAR(100) NOT NULL,
|
||||
merchant_postal_code VARCHAR(20) NOT NULL,
|
||||
merchant_country VARCHAR(2) NOT NULL DEFAULT 'LU',
|
||||
vat_number VARCHAR(50), -- e.g., "LU12345678"
|
||||
|
||||
-- VAT regime
|
||||
is_vat_registered BOOLEAN DEFAULT TRUE,
|
||||
is_oss_registered BOOLEAN DEFAULT FALSE, -- One-Stop-Shop
|
||||
|
||||
-- Invoice numbering
|
||||
invoice_prefix VARCHAR(20) DEFAULT 'INV',
|
||||
invoice_next_number INTEGER DEFAULT 1,
|
||||
|
||||
-- Optional
|
||||
payment_terms TEXT, -- e.g., "Due upon receipt"
|
||||
bank_details TEXT, -- IBAN, etc.
|
||||
footer_text TEXT, -- Legal text, thank you message
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- EU VAT rates reference table
|
||||
CREATE TABLE eu_vat_rates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
country_code VARCHAR(2) NOT NULL, -- ISO 3166-1 alpha-2
|
||||
country_name VARCHAR(100) NOT NULL,
|
||||
standard_rate DECIMAL(5,2) NOT NULL, -- e.g., 17.00
|
||||
reduced_rate_1 DECIMAL(5,2),
|
||||
reduced_rate_2 DECIMAL(5,2),
|
||||
super_reduced_rate DECIMAL(5,2),
|
||||
effective_from DATE NOT NULL,
|
||||
effective_until DATE, -- NULL = current
|
||||
|
||||
UNIQUE(country_code, effective_from)
|
||||
);
|
||||
|
||||
-- Generated invoices
|
||||
CREATE TABLE invoices (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
store_id UUID NOT NULL REFERENCES stores(id),
|
||||
order_id UUID REFERENCES orders(id), -- Can be NULL for manual invoices
|
||||
|
||||
-- Invoice identity
|
||||
invoice_number VARCHAR(50) NOT NULL, -- e.g., "INV-2024-0042"
|
||||
invoice_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
|
||||
-- Parties
|
||||
seller_details JSONB NOT NULL, -- Snapshot of store at invoice time
|
||||
buyer_details JSONB NOT NULL, -- Snapshot of customer at invoice time
|
||||
|
||||
-- VAT calculation details
|
||||
destination_country VARCHAR(2) NOT NULL,
|
||||
vat_regime VARCHAR(20) NOT NULL, -- 'domestic', 'oss', 'reverse_charge', 'origin'
|
||||
vat_rate DECIMAL(5,2) NOT NULL,
|
||||
|
||||
-- Amounts
|
||||
subtotal_net DECIMAL(12,2) NOT NULL, -- Before VAT
|
||||
vat_amount DECIMAL(12,2) NOT NULL,
|
||||
total_gross DECIMAL(12,2) NOT NULL, -- After VAT
|
||||
currency VARCHAR(3) DEFAULT 'EUR',
|
||||
|
||||
-- Line items snapshot
|
||||
line_items JSONB NOT NULL,
|
||||
|
||||
-- PDF storage
|
||||
pdf_path VARCHAR(500), -- Path to generated PDF
|
||||
pdf_generated_at TIMESTAMP,
|
||||
|
||||
-- Status
|
||||
status VARCHAR(20) DEFAULT 'draft', -- draft, issued, paid, cancelled
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
UNIQUE(store_id, invoice_number)
|
||||
);
|
||||
```
|
||||
|
||||
### Line Items JSONB Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"description": "Product Name",
|
||||
"sku": "ABC123",
|
||||
"quantity": 2,
|
||||
"unit_price_net": 25.00,
|
||||
"vat_rate": 17.00,
|
||||
"vat_amount": 8.50,
|
||||
"line_total_net": 50.00,
|
||||
"line_total_gross": 58.50
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Service Layer
|
||||
|
||||
### VATService
|
||||
|
||||
```python
|
||||
# app/services/vat_service.py
|
||||
|
||||
from decimal import Decimal
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
class VATService:
|
||||
"""Handles VAT calculation logic for EU cross-border sales."""
|
||||
|
||||
# Fallback rates if DB lookup fails
|
||||
DEFAULT_RATES = {
|
||||
'LU': Decimal('17.00'),
|
||||
'DE': Decimal('19.00'),
|
||||
'FR': Decimal('20.00'),
|
||||
'BE': Decimal('21.00'),
|
||||
'NL': Decimal('21.00'),
|
||||
'AT': Decimal('20.00'),
|
||||
'IT': Decimal('22.00'),
|
||||
'ES': Decimal('21.00'),
|
||||
# ... etc
|
||||
}
|
||||
|
||||
def __init__(self, db_session):
|
||||
self.db = db_session
|
||||
|
||||
def get_vat_rate(self, country_code: str, as_of: date = None) -> Decimal:
|
||||
"""Get current VAT rate for a country."""
|
||||
as_of = as_of or date.today()
|
||||
|
||||
# Try DB first
|
||||
rate = self.db.query(EUVATRate).filter(
|
||||
EUVATRate.country_code == country_code,
|
||||
EUVATRate.effective_from <= as_of,
|
||||
(EUVATRate.effective_until.is_(None) | (EUVATRate.effective_until >= as_of))
|
||||
).first()
|
||||
|
||||
if rate:
|
||||
return rate.standard_rate
|
||||
|
||||
# Fallback
|
||||
return self.DEFAULT_RATES.get(country_code, Decimal('0.00'))
|
||||
|
||||
def determine_vat_regime(
|
||||
self,
|
||||
seller_country: str,
|
||||
buyer_country: str,
|
||||
buyer_vat_number: Optional[str],
|
||||
seller_is_oss: bool,
|
||||
seller_exceeded_threshold: bool = False
|
||||
) -> tuple[str, Decimal]:
|
||||
"""
|
||||
Determine which VAT regime applies and the rate.
|
||||
|
||||
Returns: (regime_name, vat_rate)
|
||||
"""
|
||||
# B2B with valid VAT number = reverse charge
|
||||
if buyer_vat_number and self._validate_vat_number(buyer_vat_number):
|
||||
return ('reverse_charge', Decimal('0.00'))
|
||||
|
||||
# Domestic sale
|
||||
if seller_country == buyer_country:
|
||||
return ('domestic', self.get_vat_rate(seller_country))
|
||||
|
||||
# Cross-border B2C
|
||||
if seller_is_oss:
|
||||
# OSS: destination country VAT
|
||||
return ('oss', self.get_vat_rate(buyer_country))
|
||||
|
||||
if seller_exceeded_threshold:
|
||||
# Should be OSS but isn't - use destination anyway (compliance issue)
|
||||
return ('oss_required', self.get_vat_rate(buyer_country))
|
||||
|
||||
# Under threshold: origin country VAT
|
||||
return ('origin', self.get_vat_rate(seller_country))
|
||||
|
||||
def _validate_vat_number(self, vat_number: str) -> bool:
|
||||
"""
|
||||
Validate EU VAT number format.
|
||||
For production: integrate with VIES API.
|
||||
"""
|
||||
if not vat_number or len(vat_number) < 4:
|
||||
return False
|
||||
|
||||
# Basic format check: 2-letter country + numbers
|
||||
country = vat_number[:2].upper()
|
||||
return country in self.DEFAULT_RATES
|
||||
|
||||
def calculate_invoice_totals(
|
||||
self,
|
||||
line_items: list[dict],
|
||||
vat_rate: Decimal
|
||||
) -> dict:
|
||||
"""Calculate invoice totals with VAT."""
|
||||
subtotal_net = Decimal('0.00')
|
||||
|
||||
for item in line_items:
|
||||
quantity = Decimal(str(item['quantity']))
|
||||
unit_price = Decimal(str(item['unit_price_net']))
|
||||
line_net = quantity * unit_price
|
||||
|
||||
item['line_total_net'] = float(line_net)
|
||||
item['vat_rate'] = float(vat_rate)
|
||||
item['vat_amount'] = float(line_net * vat_rate / 100)
|
||||
item['line_total_gross'] = float(line_net + line_net * vat_rate / 100)
|
||||
|
||||
subtotal_net += line_net
|
||||
|
||||
vat_amount = subtotal_net * vat_rate / 100
|
||||
total_gross = subtotal_net + vat_amount
|
||||
|
||||
return {
|
||||
'subtotal_net': float(subtotal_net),
|
||||
'vat_rate': float(vat_rate),
|
||||
'vat_amount': float(vat_amount),
|
||||
'total_gross': float(total_gross),
|
||||
'line_items': line_items
|
||||
}
|
||||
```
|
||||
|
||||
### InvoiceService
|
||||
|
||||
```python
|
||||
# app/services/invoice_service.py
|
||||
|
||||
class InvoiceService:
|
||||
"""Generate and manage invoices."""
|
||||
|
||||
def __init__(self, db_session, vat_service: VATService):
|
||||
self.db = db_session
|
||||
self.vat = vat_service
|
||||
|
||||
def create_invoice_from_order(
|
||||
self,
|
||||
order_id: UUID,
|
||||
store_id: UUID
|
||||
) -> Invoice:
|
||||
"""Generate invoice from an existing order."""
|
||||
order = self.db.query(Order).get(order_id)
|
||||
vat_settings = self.db.query(StoreVATSettings).filter_by(
|
||||
store_id=store_id
|
||||
).first()
|
||||
|
||||
if not vat_settings:
|
||||
raise ValueError("Store VAT settings not configured")
|
||||
|
||||
# Determine VAT regime
|
||||
regime, rate = self.vat.determine_vat_regime(
|
||||
seller_country=vat_settings.merchant_country,
|
||||
buyer_country=order.shipping_country,
|
||||
buyer_vat_number=order.customer_vat_number,
|
||||
seller_is_oss=vat_settings.is_oss_registered
|
||||
)
|
||||
|
||||
# Prepare line items
|
||||
line_items = [
|
||||
{
|
||||
'description': item.product_name,
|
||||
'sku': item.sku,
|
||||
'quantity': item.quantity,
|
||||
'unit_price_net': float(item.unit_price)
|
||||
}
|
||||
for item in order.items
|
||||
]
|
||||
|
||||
# Calculate totals
|
||||
totals = self.vat.calculate_invoice_totals(line_items, rate)
|
||||
|
||||
# Generate invoice number
|
||||
invoice_number = self._generate_invoice_number(vat_settings)
|
||||
|
||||
# Create invoice
|
||||
invoice = Invoice(
|
||||
store_id=store_id,
|
||||
order_id=order_id,
|
||||
invoice_number=invoice_number,
|
||||
invoice_date=date.today(),
|
||||
seller_details=self._snapshot_seller(vat_settings),
|
||||
buyer_details=self._snapshot_buyer(order),
|
||||
destination_country=order.shipping_country,
|
||||
vat_regime=regime,
|
||||
vat_rate=rate,
|
||||
subtotal_net=totals['subtotal_net'],
|
||||
vat_amount=totals['vat_amount'],
|
||||
total_gross=totals['total_gross'],
|
||||
line_items={'items': totals['line_items']},
|
||||
status='issued'
|
||||
)
|
||||
|
||||
self.db.add(invoice)
|
||||
self.db.commit()
|
||||
|
||||
return invoice
|
||||
|
||||
def _generate_invoice_number(self, settings: StoreVATSettings) -> str:
|
||||
"""Generate next invoice number and increment counter."""
|
||||
year = date.today().year
|
||||
number = settings.invoice_next_number
|
||||
|
||||
invoice_number = f"{settings.invoice_prefix}-{year}-{number:04d}"
|
||||
|
||||
settings.invoice_next_number += 1
|
||||
self.db.commit()
|
||||
|
||||
return invoice_number
|
||||
|
||||
def _snapshot_seller(self, settings: StoreVATSettings) -> dict:
|
||||
"""Capture seller details at invoice time."""
|
||||
return {
|
||||
'merchant_name': settings.merchant_name,
|
||||
'address': settings.merchant_address,
|
||||
'city': settings.merchant_city,
|
||||
'postal_code': settings.merchant_postal_code,
|
||||
'country': settings.merchant_country,
|
||||
'vat_number': settings.vat_number
|
||||
}
|
||||
|
||||
def _snapshot_buyer(self, order: Order) -> dict:
|
||||
"""Capture buyer details at invoice time."""
|
||||
return {
|
||||
'name': f"{order.shipping_first_name} {order.shipping_last_name}",
|
||||
'merchant': order.shipping_merchant,
|
||||
'address': order.shipping_address,
|
||||
'city': order.shipping_city,
|
||||
'postal_code': order.shipping_postal_code,
|
||||
'country': order.shipping_country,
|
||||
'vat_number': order.customer_vat_number
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PDF Generation
|
||||
|
||||
### Using WeasyPrint
|
||||
|
||||
```python
|
||||
# app/services/invoice_pdf_service.py
|
||||
|
||||
from weasyprint import HTML, CSS
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
class InvoicePDFService:
|
||||
"""Generate PDF invoices."""
|
||||
|
||||
def __init__(self, template_dir: str = 'app/templates/invoices'):
|
||||
self.env = Environment(loader=FileSystemLoader(template_dir))
|
||||
|
||||
def generate_pdf(self, invoice: Invoice) -> bytes:
|
||||
"""Generate PDF bytes from invoice."""
|
||||
template = self.env.get_template('invoice.html')
|
||||
|
||||
html_content = template.render(
|
||||
invoice=invoice,
|
||||
seller=invoice.seller_details,
|
||||
buyer=invoice.buyer_details,
|
||||
items=invoice.line_items['items'],
|
||||
vat_label=self._get_vat_label(invoice.vat_regime)
|
||||
)
|
||||
|
||||
pdf_bytes = HTML(string=html_content).write_pdf(
|
||||
stylesheets=[CSS(filename='app/static/css/invoice.css')]
|
||||
)
|
||||
|
||||
return pdf_bytes
|
||||
|
||||
def _get_vat_label(self, regime: str) -> str:
|
||||
"""Human-readable VAT regime label."""
|
||||
labels = {
|
||||
'domestic': 'TVA Luxembourg',
|
||||
'oss': 'TVA (OSS - pays de destination)',
|
||||
'reverse_charge': 'Autoliquidation (Reverse Charge)',
|
||||
'origin': 'TVA pays d\'origine'
|
||||
}
|
||||
return labels.get(regime, 'TVA')
|
||||
```
|
||||
|
||||
### Invoice HTML Template
|
||||
|
||||
```html
|
||||
<!-- app/templates/invoices/invoice.html -->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; font-size: 11pt; }
|
||||
.header { display: flex; justify-content: space-between; margin-bottom: 30px; }
|
||||
.invoice-title { font-size: 24pt; color: #333; }
|
||||
.parties { display: flex; justify-content: space-between; margin-bottom: 30px; }
|
||||
.party-box { width: 45%; }
|
||||
.party-label { font-weight: bold; color: #666; margin-bottom: 5px; }
|
||||
table { width: 100%; border-collapse: collapse; margin-bottom: 30px; }
|
||||
th { background: #f5f5f5; padding: 10px; text-align: left; border-bottom: 2px solid #ddd; }
|
||||
td { padding: 10px; border-bottom: 1px solid #eee; }
|
||||
.amount { text-align: right; }
|
||||
.totals { width: 300px; margin-left: auto; }
|
||||
.totals td { padding: 5px 10px; }
|
||||
.totals .total-row { font-weight: bold; font-size: 14pt; border-top: 2px solid #333; }
|
||||
.footer { margin-top: 50px; font-size: 9pt; color: #666; }
|
||||
.vat-note { background: #f9f9f9; padding: 10px; margin-top: 20px; font-size: 9pt; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="invoice-title">FACTURE</div>
|
||||
<div>
|
||||
<strong>{{ invoice.invoice_number }}</strong><br>
|
||||
Date: {{ invoice.invoice_date.strftime('%d/%m/%Y') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="parties">
|
||||
<div class="party-box">
|
||||
<div class="party-label">De:</div>
|
||||
<strong>{{ seller.merchant_name }}</strong><br>
|
||||
{{ seller.address }}<br>
|
||||
{{ seller.postal_code }} {{ seller.city }}<br>
|
||||
{{ seller.country }}<br>
|
||||
{% if seller.vat_number %}TVA: {{ seller.vat_number }}{% endif %}
|
||||
</div>
|
||||
<div class="party-box">
|
||||
<div class="party-label">Facturé à:</div>
|
||||
<strong>{{ buyer.name }}</strong><br>
|
||||
{% if buyer.merchant %}{{ buyer.merchant }}<br>{% endif %}
|
||||
{{ buyer.address }}<br>
|
||||
{{ buyer.postal_code }} {{ buyer.city }}<br>
|
||||
{{ buyer.country }}<br>
|
||||
{% if buyer.vat_number %}TVA: {{ buyer.vat_number }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if invoice.order_id %}
|
||||
<p><strong>Référence commande:</strong> {{ invoice.order_id }}</p>
|
||||
{% endif %}
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th>Qté</th>
|
||||
<th class="amount">Prix unit. HT</th>
|
||||
<th class="amount">Total HT</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in items %}
|
||||
<tr>
|
||||
<td>{{ item.description }}{% if item.sku %} <small>({{ item.sku }})</small>{% endif %}</td>
|
||||
<td>{{ item.quantity }}</td>
|
||||
<td class="amount">€{{ "%.2f"|format(item.unit_price_net) }}</td>
|
||||
<td class="amount">€{{ "%.2f"|format(item.line_total_net) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table class="totals">
|
||||
<tr>
|
||||
<td>Sous-total HT:</td>
|
||||
<td class="amount">€{{ "%.2f"|format(invoice.subtotal_net) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ vat_label }} ({{ invoice.vat_rate }}%):</td>
|
||||
<td class="amount">€{{ "%.2f"|format(invoice.vat_amount) }}</td>
|
||||
</tr>
|
||||
<tr class="total-row">
|
||||
<td>TOTAL TTC:</td>
|
||||
<td class="amount">€{{ "%.2f"|format(invoice.total_gross) }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{% if invoice.vat_regime == 'reverse_charge' %}
|
||||
<div class="vat-note">
|
||||
<strong>Autoliquidation de la TVA</strong><br>
|
||||
En application de l'article 196 de la directive 2006/112/CE, la TVA est due par le preneur.
|
||||
</div>
|
||||
{% elif invoice.vat_regime == 'oss' %}
|
||||
<div class="vat-note">
|
||||
<strong>Régime OSS (One-Stop-Shop)</strong><br>
|
||||
TVA calculée selon le taux du pays de destination ({{ invoice.destination_country }}).
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="footer">
|
||||
{% if seller.payment_terms %}{{ seller.payment_terms }}<br>{% endif %}
|
||||
{% if seller.bank_details %}{{ seller.bank_details }}<br>{% endif %}
|
||||
{% if seller.footer_text %}{{ seller.footer_text }}{% endif %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
```python
|
||||
# app/api/v1/store/invoices.py
|
||||
|
||||
@router.post("/orders/{order_id}/invoice")
|
||||
async def create_invoice_from_order(
|
||||
order_id: UUID,
|
||||
store: Store = Depends(get_current_store),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Generate invoice for an order."""
|
||||
service = InvoiceService(db, VATService(db))
|
||||
invoice = service.create_invoice_from_order(order_id, store.id)
|
||||
return InvoiceResponse.from_orm(invoice)
|
||||
|
||||
@router.get("/invoices/{invoice_id}/pdf")
|
||||
async def download_invoice_pdf(
|
||||
invoice_id: UUID,
|
||||
store: Store = Depends(get_current_store),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Download invoice as PDF."""
|
||||
invoice = db.query(Invoice).filter(
|
||||
Invoice.id == invoice_id,
|
||||
Invoice.store_id == store.id
|
||||
).first()
|
||||
|
||||
if not invoice:
|
||||
raise HTTPException(404, "Invoice not found")
|
||||
|
||||
pdf_service = InvoicePDFService()
|
||||
pdf_bytes = pdf_service.generate_pdf(invoice)
|
||||
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={invoice.invoice_number}.pdf"
|
||||
}
|
||||
)
|
||||
|
||||
@router.get("/invoices")
|
||||
async def list_invoices(
|
||||
store: Store = Depends(get_current_store),
|
||||
db: Session = Depends(get_db),
|
||||
skip: int = 0,
|
||||
limit: int = 50
|
||||
):
|
||||
"""List all invoices for store."""
|
||||
invoices = db.query(Invoice).filter(
|
||||
Invoice.store_id == store.id
|
||||
).order_by(Invoice.invoice_date.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
return [InvoiceResponse.from_orm(inv) for inv in invoices]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## UI Integration
|
||||
|
||||
### Order Detail - Invoice Button
|
||||
|
||||
```html
|
||||
<!-- In order detail view -->
|
||||
{% if not order.invoice %}
|
||||
<button
|
||||
@click="generateInvoice('{{ order.id }}')"
|
||||
class="btn btn-secondary">
|
||||
<i data-lucide="file-text"></i>
|
||||
Generate Invoice
|
||||
</button>
|
||||
{% else %}
|
||||
<a
|
||||
href="/api/v1/store/invoices/{{ order.invoice.id }}/pdf"
|
||||
class="btn btn-secondary"
|
||||
target="_blank">
|
||||
<i data-lucide="download"></i>
|
||||
Download Invoice ({{ order.invoice.invoice_number }})
|
||||
</a>
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
### Store Settings - VAT Configuration
|
||||
|
||||
```html
|
||||
<!-- New settings tab for VAT/Invoice configuration -->
|
||||
<div class="settings-section">
|
||||
<h3>Invoice Settings</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Merchant Name (for invoices)</label>
|
||||
<input type="text" x-model="settings.merchant_name" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Merchant Address</label>
|
||||
<textarea x-model="settings.merchant_address" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Postal Code</label>
|
||||
<input type="text" x-model="settings.merchant_postal_code">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>City</label>
|
||||
<input type="text" x-model="settings.merchant_city">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>VAT Number</label>
|
||||
<input type="text" x-model="settings.vat_number" placeholder="LU12345678">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" x-model="settings.is_oss_registered">
|
||||
Registered for OSS (One-Stop-Shop)
|
||||
</label>
|
||||
<small>Check this if you're registered to report EU VAT through the OSS system</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Invoice Number Prefix</label>
|
||||
<input type="text" x-model="settings.invoice_prefix" placeholder="INV">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Payment Terms</label>
|
||||
<input type="text" x-model="settings.payment_terms" placeholder="Due upon receipt">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Bank Details (optional)</label>
|
||||
<textarea x-model="settings.bank_details" rows="2" placeholder="IBAN: LU..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Effort
|
||||
|
||||
| Component | Estimate |
|
||||
|-----------|----------|
|
||||
| Database migrations | 0.5 day |
|
||||
| VAT rates seed data | 0.5 day |
|
||||
| VATService | 1 day |
|
||||
| InvoiceService | 1 day |
|
||||
| PDF generation | 1.5 days |
|
||||
| API endpoints | 0.5 day |
|
||||
| UI (settings + order button) | 1 day |
|
||||
| Testing | 1 day |
|
||||
| **Total** | **~7 days** |
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **VIES VAT Validation** - Verify B2B VAT numbers via EU API
|
||||
2. **Credit Notes** - For returns/refunds
|
||||
3. **Batch Invoice Generation** - Generate for multiple orders
|
||||
4. **Email Delivery** - Send invoice PDF by email
|
||||
5. **Accounting Export** - CSV/XML for accounting software
|
||||
Reference in New Issue
Block a user