- Auto-fixed 4,496 lint issues (import sorting, modern syntax, etc.) - Added ignore rules for patterns intentional in this codebase: E402 (late imports), E712 (SQLAlchemy filters), B904 (raise from), SIM108/SIM105/SIM117 (readability preferences) - Added per-file ignores for tests and scripts - Excluded broken scripts/rename_terminology.py (has curly quotes) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
245 lines
7.1 KiB
Python
245 lines
7.1 KiB
Python
# app/utils/vat.py
|
|
"""
|
|
VAT calculation utilities for the OMS.
|
|
|
|
Provides centralized VAT logic used by both order_service and invoice_service
|
|
to ensure consistency between order tax calculation and invoice VAT.
|
|
|
|
VAT Logic:
|
|
- Luxembourg domestic: 17% (standard), 8% (reduced), 3% (super-reduced), 14% (intermediate)
|
|
- EU cross-border B2C with OSS: Use destination country VAT rate
|
|
- EU cross-border B2C without OSS: Use Luxembourg VAT rate (origin principle)
|
|
- EU B2B with valid VAT number: Reverse charge (0% VAT)
|
|
- Non-EU: VAT exempt (0%)
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
from decimal import Decimal
|
|
from enum import Enum
|
|
|
|
|
|
class VATRegime(str, Enum):
|
|
"""VAT regime for order/invoice calculation."""
|
|
|
|
DOMESTIC = "domestic" # Same country as seller
|
|
OSS = "oss" # EU cross-border with OSS registration
|
|
REVERSE_CHARGE = "reverse_charge" # B2B with valid VAT number
|
|
ORIGIN = "origin" # Cross-border without OSS (use origin VAT)
|
|
EXEMPT = "exempt" # VAT exempt (non-EU)
|
|
|
|
|
|
@dataclass
|
|
class VATResult:
|
|
"""Result of VAT determination."""
|
|
|
|
regime: VATRegime
|
|
rate: Decimal
|
|
destination_country: str | None
|
|
label: str | None
|
|
|
|
|
|
# EU VAT rates by country code (2024 standard rates)
|
|
EU_VAT_RATES: dict[str, Decimal] = {
|
|
"AT": Decimal("20.00"), # Austria
|
|
"BE": Decimal("21.00"), # Belgium
|
|
"BG": Decimal("20.00"), # Bulgaria
|
|
"HR": Decimal("25.00"), # Croatia
|
|
"CY": Decimal("19.00"), # Cyprus
|
|
"CZ": Decimal("21.00"), # Czech Republic
|
|
"DK": Decimal("25.00"), # Denmark
|
|
"EE": Decimal("22.00"), # Estonia
|
|
"FI": Decimal("24.00"), # Finland
|
|
"FR": Decimal("20.00"), # France
|
|
"DE": Decimal("19.00"), # Germany
|
|
"GR": Decimal("24.00"), # Greece
|
|
"HU": Decimal("27.00"), # Hungary
|
|
"IE": Decimal("23.00"), # Ireland
|
|
"IT": Decimal("22.00"), # Italy
|
|
"LV": Decimal("21.00"), # Latvia
|
|
"LT": Decimal("21.00"), # Lithuania
|
|
"LU": Decimal("17.00"), # Luxembourg (standard)
|
|
"MT": Decimal("18.00"), # Malta
|
|
"NL": Decimal("21.00"), # Netherlands
|
|
"PL": Decimal("23.00"), # Poland
|
|
"PT": Decimal("23.00"), # Portugal
|
|
"RO": Decimal("19.00"), # Romania
|
|
"SK": Decimal("20.00"), # Slovakia
|
|
"SI": Decimal("22.00"), # Slovenia
|
|
"ES": Decimal("21.00"), # Spain
|
|
"SE": Decimal("25.00"), # Sweden
|
|
}
|
|
|
|
# Luxembourg specific VAT rates
|
|
LU_VAT_RATES = {
|
|
"standard": Decimal("17.00"),
|
|
"intermediate": Decimal("14.00"),
|
|
"reduced": Decimal("8.00"),
|
|
"super_reduced": Decimal("3.00"),
|
|
}
|
|
|
|
# Country names for labels
|
|
COUNTRY_NAMES: dict[str, str] = {
|
|
"AT": "Austria",
|
|
"BE": "Belgium",
|
|
"BG": "Bulgaria",
|
|
"HR": "Croatia",
|
|
"CY": "Cyprus",
|
|
"CZ": "Czech Republic",
|
|
"DK": "Denmark",
|
|
"EE": "Estonia",
|
|
"FI": "Finland",
|
|
"FR": "France",
|
|
"DE": "Germany",
|
|
"GR": "Greece",
|
|
"HU": "Hungary",
|
|
"IE": "Ireland",
|
|
"IT": "Italy",
|
|
"LV": "Latvia",
|
|
"LT": "Lithuania",
|
|
"LU": "Luxembourg",
|
|
"MT": "Malta",
|
|
"NL": "Netherlands",
|
|
"PL": "Poland",
|
|
"PT": "Portugal",
|
|
"RO": "Romania",
|
|
"SK": "Slovakia",
|
|
"SI": "Slovenia",
|
|
"ES": "Spain",
|
|
"SE": "Sweden",
|
|
}
|
|
|
|
|
|
def get_vat_rate_for_country(country_iso: str) -> Decimal:
|
|
"""
|
|
Get standard VAT rate for EU country.
|
|
|
|
Args:
|
|
country_iso: ISO 2-letter country code
|
|
|
|
Returns:
|
|
VAT rate as Decimal (0.00 for non-EU countries)
|
|
"""
|
|
return EU_VAT_RATES.get(country_iso.upper(), Decimal("0.00"))
|
|
|
|
|
|
def get_vat_rate_label(country_iso: str, vat_rate: Decimal) -> str:
|
|
"""
|
|
Get human-readable VAT rate label.
|
|
|
|
Args:
|
|
country_iso: ISO 2-letter country code
|
|
vat_rate: VAT rate as Decimal
|
|
|
|
Returns:
|
|
Human-readable label (e.g., "Luxembourg VAT 17%")
|
|
"""
|
|
country_name = COUNTRY_NAMES.get(country_iso.upper(), country_iso)
|
|
return f"{country_name} VAT {vat_rate}%"
|
|
|
|
|
|
def is_eu_country(country_iso: str) -> bool:
|
|
"""Check if country is in the EU."""
|
|
return country_iso.upper() in EU_VAT_RATES
|
|
|
|
|
|
def determine_vat_regime(
|
|
seller_country: str,
|
|
buyer_country: str,
|
|
buyer_vat_number: str | None = None,
|
|
seller_oss_registered: bool = False,
|
|
) -> VATResult:
|
|
"""
|
|
Determine VAT regime and rate for an order/invoice.
|
|
|
|
VAT Decision Logic:
|
|
1. Same country = domestic VAT
|
|
2. B2B with valid VAT number = reverse charge (0%)
|
|
3. Cross-border + OSS registered = destination country VAT
|
|
4. Cross-border + no OSS = origin country VAT
|
|
5. Non-EU = VAT exempt (0%)
|
|
|
|
Args:
|
|
seller_country: Seller's country (ISO 2-letter code)
|
|
buyer_country: Buyer's country (ISO 2-letter code)
|
|
buyer_vat_number: Buyer's VAT number (for B2B detection)
|
|
seller_oss_registered: Whether seller is registered for OSS
|
|
|
|
Returns:
|
|
VATResult with regime, rate, destination country, and label
|
|
"""
|
|
seller_country = seller_country.upper() if seller_country else "LU"
|
|
buyer_country = buyer_country.upper() if buyer_country else "LU"
|
|
|
|
# Same country = domestic VAT
|
|
if seller_country == buyer_country:
|
|
vat_rate = get_vat_rate_for_country(seller_country)
|
|
label = get_vat_rate_label(seller_country, vat_rate) if vat_rate > 0 else None
|
|
return VATResult(
|
|
regime=VATRegime.DOMESTIC,
|
|
rate=vat_rate,
|
|
destination_country=None,
|
|
label=label,
|
|
)
|
|
|
|
# Different EU countries
|
|
if is_eu_country(buyer_country):
|
|
# B2B with valid VAT number = reverse charge
|
|
if buyer_vat_number:
|
|
return VATResult(
|
|
regime=VATRegime.REVERSE_CHARGE,
|
|
rate=Decimal("0.00"),
|
|
destination_country=buyer_country,
|
|
label="Reverse charge",
|
|
)
|
|
|
|
# B2C cross-border
|
|
if seller_oss_registered:
|
|
# OSS: use destination country VAT
|
|
vat_rate = get_vat_rate_for_country(buyer_country)
|
|
label = get_vat_rate_label(buyer_country, vat_rate)
|
|
return VATResult(
|
|
regime=VATRegime.OSS,
|
|
rate=vat_rate,
|
|
destination_country=buyer_country,
|
|
label=label,
|
|
)
|
|
# No OSS: use origin country VAT
|
|
vat_rate = get_vat_rate_for_country(seller_country)
|
|
label = get_vat_rate_label(seller_country, vat_rate)
|
|
return VATResult(
|
|
regime=VATRegime.ORIGIN,
|
|
rate=vat_rate,
|
|
destination_country=buyer_country,
|
|
label=label,
|
|
)
|
|
|
|
# Non-EU = VAT exempt
|
|
return VATResult(
|
|
regime=VATRegime.EXEMPT,
|
|
rate=Decimal("0.00"),
|
|
destination_country=buyer_country,
|
|
label="VAT exempt",
|
|
)
|
|
|
|
|
|
def calculate_vat_amount(subtotal_cents: int, vat_rate: Decimal) -> int:
|
|
"""
|
|
Calculate VAT amount from subtotal.
|
|
|
|
Args:
|
|
subtotal_cents: Subtotal in cents
|
|
vat_rate: VAT rate as percentage (e.g., 17.00 for 17%)
|
|
|
|
Returns:
|
|
VAT amount in cents
|
|
"""
|
|
if vat_rate <= 0:
|
|
return 0
|
|
|
|
# Calculate: tax = subtotal * (rate / 100)
|
|
subtotal_decimal = Decimal(str(subtotal_cents))
|
|
tax_decimal = subtotal_decimal * (vat_rate / Decimal("100"))
|
|
|
|
# Round to nearest cent
|
|
return int(round(tax_decimal))
|