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:
2026-01-02 20:31:48 +01:00
parent b5b32fb351
commit 82c07c165f
21 changed files with 2224 additions and 85 deletions

245
app/utils/vat.py Normal file
View 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))