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>
735 lines
23 KiB
Markdown
735 lines
23 KiB
Markdown
# 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
|