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:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user