feat: add customer profile, VAT alignment, and fix shop auth
Customer Profile: - Add profile API (GET/PUT /api/v1/shop/profile) - Add password change endpoint (PUT /api/v1/shop/profile/password) - Implement full profile page with preferences and password sections - Add CustomerPasswordChange schema Shop Authentication Fixes: - Add Authorization header to all shop account API calls - Fix orders, order-detail, messages pages authentication - Add proper redirect to login on 401 responses - Fix toast message showing noqa comment in shop-layout.js VAT Calculation: - Add shared VAT utility (app/utils/vat.py) - Add VAT fields to Order model (vat_regime, vat_rate, etc.) - Align order VAT calculation with invoice settings - Add migration for VAT fields on orders Validation Framework: - Fix base_validator.py with missing methods - Add validate_file, output_results, get_exit_code methods - Fix validate_all.py import issues Documentation: - Add launch-readiness.md tracking OMS status - Update to 95% feature complete 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
245
app/utils/vat.py
Normal file
245
app/utils/vat.py
Normal file
@@ -0,0 +1,245 @@
|
||||
# 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,
|
||||
)
|
||||
else:
|
||||
# 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))
|
||||
Reference in New Issue
Block a user