Files
orion/docs/implementation/vat-invoice-feature.md
Samir Boulahtit 4d9b816072 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>
2025-12-23 23:57:02 +01:00

23 KiB

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

-- 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

{
  "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

# 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

# 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

# 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

<!-- 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

# 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

<!-- 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

<!-- 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