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

View File

@@ -477,6 +477,21 @@ class InvoiceService:
.first()
)
def get_invoice_by_order_id(
self, db: Session, vendor_id: int, order_id: int
) -> Invoice | None:
"""Get invoice by order ID."""
return (
db.query(Invoice)
.filter(
and_(
Invoice.order_id == order_id,
Invoice.vendor_id == vendor_id,
)
)
.first()
)
def list_invoices(
self,
db: Session,

View File

@@ -38,38 +38,11 @@ from app.services.subscription_service import (
TierLimitExceededException,
)
from app.utils.money import Money, cents_to_euros, euros_to_cents
# EU VAT rates by country code (2024 standard rates)
# Duplicated from invoice_service to avoid circular imports
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
}
from app.utils.vat import (
VATResult,
calculate_vat_amount,
determine_vat_regime,
)
from models.database.customer import Customer
from models.database.marketplace_product import MarketplaceProduct
from models.database.marketplace_product_translation import MarketplaceProductTranslation
@@ -125,44 +98,56 @@ class OrderService:
# =========================================================================
def _calculate_tax_for_order(
self, subtotal_cents: int, shipping_country_iso: str
) -> tuple[int, Decimal]:
self,
db: Session,
vendor_id: int,
subtotal_cents: int,
billing_country_iso: str,
buyer_vat_number: str | None = None,
) -> VATResult:
"""
Calculate tax amount for an order based on shipping destination.
Calculate tax amount for an order based on billing destination.
Uses EU VAT rates based on destination country. For B2C orders,
the tax is included in the product price, but we need to calculate
the tax component for reporting.
Uses the shared VAT utility to determine the correct VAT regime
and rate, consistent with invoice VAT calculation.
For Luxembourg vendors selling to EU countries:
- LU domestic: 17% VAT
- Other EU countries: destination country VAT rate
- Non-EU: 0% (VAT exempt)
VAT Logic:
- Same country as seller: domestic VAT
- B2B with valid VAT number: reverse charge (0%)
- Cross-border + OSS registered: destination country VAT
- Cross-border + no OSS: origin country VAT
- Non-EU: VAT exempt (0%)
Args:
db: Database session
vendor_id: Vendor ID (to get invoice settings)
subtotal_cents: Order subtotal in cents (before tax)
shipping_country_iso: ISO 2-letter country code
billing_country_iso: ISO 2-letter country code
buyer_vat_number: Buyer's VAT number for B2B detection
Returns:
tuple: (tax_amount_cents, vat_rate)
VATResult with regime, rate, destination country, and label
"""
country = shipping_country_iso.upper() if shipping_country_iso else "LU"
from models.database.invoice import VendorInvoiceSettings
# Get VAT rate for destination country (0% if non-EU)
vat_rate = EU_VAT_RATES.get(country, Decimal("0.00"))
# Get vendor invoice settings for seller country and OSS status
settings = (
db.query(VendorInvoiceSettings)
.filter(VendorInvoiceSettings.vendor_id == vendor_id)
.first()
)
if vat_rate == Decimal("0.00"):
return 0, vat_rate
# Default to Luxembourg if no settings exist
seller_country = settings.company_country if settings else "LU"
seller_oss_registered = settings.is_oss_registered if settings else False
# Calculate tax: tax = subtotal * (rate / 100)
# Using Decimal for precision, then converting to cents
subtotal_decimal = Decimal(str(subtotal_cents))
tax_decimal = subtotal_decimal * (vat_rate / Decimal("100"))
# Round to nearest cent
tax_amount_cents = int(round(tax_decimal))
return tax_amount_cents, vat_rate
# Determine VAT regime using shared utility
return determine_vat_regime(
seller_country=seller_country,
buyer_country=billing_country_iso or "LU",
buyer_vat_number=buyer_vat_number,
seller_oss_registered=seller_oss_registered,
)
# =========================================================================
# Placeholder Product Management
@@ -445,19 +430,26 @@ class OrderService:
}
)
# Calculate totals in cents
tax_amount_cents, vat_rate = self._calculate_tax_for_order(
subtotal_cents, order_data.shipping_address.country_iso
# Use billing address or shipping address for VAT
billing = order_data.billing_address or order_data.shipping_address
# Calculate VAT using vendor settings (OSS, B2B handling)
vat_result = self._calculate_tax_for_order(
db=db,
vendor_id=vendor_id,
subtotal_cents=subtotal_cents,
billing_country_iso=billing.country_iso,
buyer_vat_number=getattr(billing, 'vat_number', None),
)
# Calculate amounts in cents
tax_amount_cents = calculate_vat_amount(subtotal_cents, vat_result.rate)
shipping_amount_cents = 599 if subtotal_cents < 5000 else 0 # €5.99 / €50
discount_amount_cents = 0
total_amount_cents = Money.calculate_order_total(
subtotal_cents, tax_amount_cents, shipping_amount_cents, discount_amount_cents
)
# Use billing address or shipping address
billing = order_data.billing_address or order_data.shipping_address
# Generate order number
order_number = self._generate_order_number(db, vendor_id)
@@ -475,6 +467,11 @@ class OrderService:
discount_amount_cents=discount_amount_cents,
total_amount_cents=total_amount_cents,
currency="EUR",
# VAT information
vat_regime=vat_result.regime.value,
vat_rate=vat_result.rate,
vat_rate_label=vat_result.label,
vat_destination_country=vat_result.destination_country,
# Customer snapshot
customer_first_name=order_data.customer.first_name,
customer_last_name=order_data.customer.last_name,
@@ -987,6 +984,37 @@ class OrderService:
return orders, total
def get_customer_orders(
self,
db: Session,
vendor_id: int,
customer_id: int,
skip: int = 0,
limit: int = 50,
) -> tuple[list[Order], int]:
"""
Get orders for a specific customer.
Used by shop frontend for customer order history.
Args:
db: Database session
vendor_id: Vendor ID
customer_id: Customer ID
skip: Pagination offset
limit: Pagination limit
Returns:
Tuple of (orders, total_count)
"""
return self.get_vendor_orders(
db=db,
vendor_id=vendor_id,
skip=skip,
limit=limit,
customer_id=customer_id,
)
def get_order_stats(self, db: Session, vendor_id: int) -> dict[str, int]:
"""
Get order counts by status for a vendor.