# 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
Référence commande: {{ invoice.order_id }}
{% endif %}| Description | Qté | Prix unit. HT | Total HT |
|---|---|---|---|
| {{ item.description }}{% if item.sku %} ({{ item.sku }}){% endif %} | {{ item.quantity }} | €{{ "%.2f"|format(item.unit_price_net) }} | €{{ "%.2f"|format(item.line_total_net) }} |
| Sous-total HT: | €{{ "%.2f"|format(invoice.subtotal_net) }} |
| {{ vat_label }} ({{ invoice.vat_rate }}%): | €{{ "%.2f"|format(invoice.vat_amount) }} |
| TOTAL TTC: | €{{ "%.2f"|format(invoice.total_gross) }} |