docs: add OMS positioning strategy and implementation plan

- Add "Lightweight OMS for Letzshop Sellers" positioning strategy
- Add back-office vs marketing positioning comparison
- Update pricing tiers for OMS model (Essential/Professional/Business)
- Add VAT invoice feature technical specification
- Add comprehensive OMS feature implementation plan
- Fix code block formatting in synology doc

New docs:
- docs/marketing/strategy/back-office-positioning.md
- docs/marketing/strategy/customer-marketing-positioning.md
- docs/implementation/vat-invoice-feature.md
- docs/implementation/oms-feature-plan.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-23 23:57:02 +01:00
parent 0be95e079d
commit 4d9b816072
7 changed files with 2175 additions and 128 deletions

View File

@@ -0,0 +1,662 @@
# OMS Feature Implementation Plan
## Overview
Transform Wizamart 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 (Company → Vendor 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 |
| Vendor VAT Settings | Professional | P1 |
| EU VAT Invoice | Professional | P1 |
| Incoming Stock / PO | Professional | P1 |
| Customer CSV Export | Professional | P1 |
| Multi-vendor 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: Vendor Invoice Settings (1 day)
**Create model for vendor billing details:**
```
models/database/vendor_invoice_settings.py
```
Fields:
- `vendor_id` (FK, unique - one-to-one)
- `company_name` (legal name for invoices)
- `company_address`, `company_city`, `company_postal_code`, `company_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` (VendorLetzshopCredentials)
**Files to create/modify:**
- `models/database/vendor_invoice_settings.py` (new)
- `models/database/__init__.py` (add import)
- `models/database/vendor.py` (add relationship)
- `models/schema/invoice.py` (new - Pydantic schemas)
- `alembic/versions/xxx_add_vendor_invoice_settings.py` (migration)
---
### Step 1.2: Basic Invoice Model (0.5 day)
**Create invoice storage:**
```
models/database/invoice.py
```
Fields:
- `id`, `vendor_id` (FK)
- `order_id` (FK, nullable - for manual invoices later)
- `invoice_number` (unique per vendor)
- `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, vendor_id)` - Generate invoice from order
- `get_invoice(invoice_id, vendor_id)` - Retrieve invoice
- `list_invoices(vendor_id, skip, limit)` - List vendor invoices
- `_generate_invoice_number(settings)` - Auto-increment number
- `_snapshot_seller(settings)` - Capture vendor 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 vendor invoice endpoints:**
```
app/api/v1/vendor/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/vendor/invoices.py` (new)
- `app/api/v1/vendor/__init__.py` (add router)
---
### Step 1.6: Invoice Settings UI (0.5 day)
**Add invoice settings to vendor settings page:**
Modify existing vendor settings template to add "Invoice Settings" section:
- Company name, address fields
- VAT number
- Invoice prefix
- Payment terms
- Bank details
**Files to modify:**
- `app/templates/vendor/settings.html` (add section)
- `static/vendor/js/settings.js` (add handlers)
- `app/api/v1/vendor/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/vendor/order-detail.html` (add button)
- `static/vendor/js/order-detail.js` (add handler)
---
### Step 1.8: Tier Limits Enforcement (1 day)
**Create tier/subscription model:**
```
models/database/vendor_subscription.py
```
Fields:
- `vendor_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(vendor_id)` - Returns (allowed: bool, remaining: int)
- `increment_order_count(vendor_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/vendor_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 Vendor VAT Settings (0.5 day)
**Add OSS fields to VendorInvoiceSettings:**
- `is_oss_registered` (boolean)
- `oss_registration_country` (if different from company country)
**Files to modify:**
- `models/database/vendor_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`, `vendor_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(vendor_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(vendor_id)` - Summary of pending stock
- `list_purchase_orders(vendor_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/vendor/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/vendor/purchase-orders.html` (new)
- `static/vendor/js/purchase-orders.js` (new)
- `app/api/v1/vendor/purchase_orders.py` (new endpoints)
- `app/routes/vendor_pages.py` (add route)
- `app/templates/vendor/partials/sidebar.html` (add menu item)
- `app/templates/vendor/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(vendor_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/vendor/customers/export?format=csv
```
**Add export button to customers page:**
- "Export to CSV" button
- Downloads file directly
**Files to modify:**
- `app/api/v1/vendor/customers.py` (add export endpoint)
- `app/templates/vendor/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-Vendor Consolidated View (2 days)
**For companies with multiple Letzshop accounts:**
**New page:**
```
app/templates/vendor/multi-vendor-dashboard.html
```
Features:
- See all vendor accounts under same company
- Consolidated order count, revenue
- Switch between vendor contexts
- Unified reporting
**Requires:** Company-level authentication context (already exists via Company → Vendor relationship)
**Files to create/modify:**
- `app/templates/vendor/multi-vendor-dashboard.html` (new)
- `app/services/multi_vendor_service.py` (new)
- `app/api/v1/vendor/multi_vendor.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(vendor_id, date_from, date_to)` - Simple CSV
- `export_invoices_xml(vendor_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/vendor/accounting.py` (new endpoints)
---
### Step 3.3: API Access Documentation (1 day)
**If not already documented, create API documentation page:**
- Document existing vendor API endpoints
- Add rate limiting for API tier
- Generate API keys for vendors
**Files to create/modify:**
- `docs/api/vendor-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 | Vendor 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-vendor 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/vendor_invoice_settings.py`
- `models/database/invoice.py`
- `models/database/eu_vat_rates.py`
- `models/database/vendor_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/vendor/purchase-orders.html`
### Existing Files to Modify
- `models/database/__init__.py`
- `models/database/vendor.py`
- `app/services/order_service.py`
- `app/templates/vendor/settings.html`
- `app/templates/vendor/order-detail.html`
- `app/templates/vendor/inventory.html`
- `app/templates/vendor/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/vendor/test_invoices.py`
- `tests/integration/api/v1/vendor/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-vendor dashboard works
- [ ] Accounting export works
- [ ] API access documented

View 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 vendor
CREATE TABLE vendor_vat_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
vendor_id UUID NOT NULL REFERENCES vendors(id),
-- Company details for invoices
company_name VARCHAR(255) NOT NULL,
company_address TEXT NOT NULL,
company_city VARCHAR(100) NOT NULL,
company_postal_code VARCHAR(20) NOT NULL,
company_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(),
vendor_id UUID NOT NULL REFERENCES vendors(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 vendor 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(vendor_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,
vendor_id: UUID
) -> Invoice:
"""Generate invoice from an existing order."""
order = self.db.query(Order).get(order_id)
vat_settings = self.db.query(VendorVATSettings).filter_by(
vendor_id=vendor_id
).first()
if not vat_settings:
raise ValueError("Vendor VAT settings not configured")
# Determine VAT regime
regime, rate = self.vat.determine_vat_regime(
seller_country=vat_settings.company_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(
vendor_id=vendor_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: VendorVATSettings) -> 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: VendorVATSettings) -> dict:
"""Capture seller details at invoice time."""
return {
'company_name': settings.company_name,
'address': settings.company_address,
'city': settings.company_city,
'postal_code': settings.company_postal_code,
'country': settings.company_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}",
'company': order.shipping_company,
'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.company_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.company %}{{ buyer.company }}<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/vendor/invoices.py
@router.post("/orders/{order_id}/invoice")
async def create_invoice_from_order(
order_id: UUID,
vendor: Vendor = Depends(get_current_vendor),
db: Session = Depends(get_db)
):
"""Generate invoice for an order."""
service = InvoiceService(db, VATService(db))
invoice = service.create_invoice_from_order(order_id, vendor.id)
return InvoiceResponse.from_orm(invoice)
@router.get("/invoices/{invoice_id}/pdf")
async def download_invoice_pdf(
invoice_id: UUID,
vendor: Vendor = Depends(get_current_vendor),
db: Session = Depends(get_db)
):
"""Download invoice as PDF."""
invoice = db.query(Invoice).filter(
Invoice.id == invoice_id,
Invoice.vendor_id == vendor.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(
vendor: Vendor = Depends(get_current_vendor),
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 50
):
"""List all invoices for vendor."""
invoices = db.query(Invoice).filter(
Invoice.vendor_id == vendor.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/vendor/invoices/{{ order.invoice.id }}/pdf"
class="btn btn-secondary"
target="_blank">
<i data-lucide="download"></i>
Download Invoice ({{ order.invoice.invoice_number }})
</a>
{% endif %}
```
### Vendor Settings - VAT Configuration
```html
<!-- New settings tab for VAT/Invoice configuration -->
<div class="settings-section">
<h3>Invoice Settings</h3>
<div class="form-group">
<label>Company Name (for invoices)</label>
<input type="text" x-model="settings.company_name" required>
</div>
<div class="form-group">
<label>Company Address</label>
<textarea x-model="settings.company_address" rows="3"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>Postal Code</label>
<input type="text" x-model="settings.company_postal_code">
</div>
<div class="form-group">
<label>City</label>
<input type="text" x-model="settings.company_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