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

@@ -21,7 +21,7 @@ Authentication:
from fastapi import APIRouter
# Import shop routers
from . import addresses, auth, carts, content_pages, messages, orders, products
from . import addresses, auth, carts, content_pages, messages, orders, products, profile
# Create shop router
router = APIRouter()
@@ -48,6 +48,9 @@ router.include_router(orders.router, tags=["shop-orders"])
# Messages (authenticated)
router.include_router(messages.router, tags=["shop-messages"])
# Profile (authenticated)
router.include_router(profile.router, tags=["shop-profile"])
# Content pages (public)
router.include_router(
content_pages.router, prefix="/content-pages", tags=["shop-content-pages"]

View File

@@ -13,15 +13,19 @@ Customer Context: get_current_customer_api returns Customer directly
import logging
from datetime import UTC, datetime
from pathlib import Path as FilePath
from fastapi import APIRouter, Depends, Path, Query, Request
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from app.api.deps import get_current_customer_api
from app.core.database import get_db
from app.exceptions import VendorNotFoundException
from app.exceptions.invoice import InvoicePDFNotFoundException
from app.services.cart_service import cart_service
from app.services.email_service import EmailService
from app.services.invoice_service import invoice_service
from app.services.order_service import order_service
from app.utils.money import cents_to_euros
from models.database.customer import Customer
@@ -226,3 +230,101 @@ def get_order_details(
raise OrderNotFoundException(str(order_id))
return OrderDetailResponse.model_validate(order)
@router.get("/orders/{order_id}/invoice")
def download_order_invoice(
request: Request,
order_id: int = Path(..., description="Order ID", gt=0),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Download invoice PDF for a customer's order.
Vendor is automatically determined from request context.
Customer can only download invoices for their own orders.
Invoice is auto-generated if it doesn't exist.
Path Parameters:
- order_id: ID of the order to get invoice for
"""
from app.exceptions import OrderNotFoundException
# Get vendor from middleware
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] download_order_invoice: order {order_id}",
extra={
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"customer_id": customer.id,
"order_id": order_id,
},
)
# Get order
order = order_service.get_order(db=db, vendor_id=vendor.id, order_id=order_id)
# Verify order belongs to customer
if order.customer_id != customer.id:
raise OrderNotFoundException(str(order_id))
# Only allow invoice download for orders that are at least processing
allowed_statuses = ["processing", "partially_shipped", "shipped", "delivered", "completed"]
if order.status not in allowed_statuses:
from app.exceptions import ValidationException
raise ValidationException("Invoice not available for pending orders")
# Check if invoice exists for this order (via service layer)
invoice = invoice_service.get_invoice_by_order_id(
db=db, vendor_id=vendor.id, order_id=order_id
)
# Create invoice if it doesn't exist
if not invoice:
logger.info(f"Creating invoice for order {order_id} (customer download)")
invoice = invoice_service.create_invoice_from_order(
db=db,
vendor_id=vendor.id,
order_id=order_id,
)
db.commit()
# Get or generate PDF
pdf_path = invoice_service.get_pdf_path(
db=db,
vendor_id=vendor.id,
invoice_id=invoice.id,
)
if not pdf_path:
# Generate PDF
pdf_path = invoice_service.generate_pdf(
db=db,
vendor_id=vendor.id,
invoice_id=invoice.id,
)
# Verify file exists
if not FilePath(pdf_path).exists():
raise InvoicePDFNotFoundException(invoice.id)
filename = f"invoice-{invoice.invoice_number}.pdf"
logger.info(
f"Customer {customer.id} downloading invoice {invoice.invoice_number} for order {order.order_number}"
)
return FileResponse(
path=pdf_path,
media_type="application/pdf",
filename=filename,
headers={
"Content-Disposition": f'attachment; filename="{filename}"'
},
)

161
app/api/v1/shop/profile.py Normal file
View File

@@ -0,0 +1,161 @@
# app/api/v1/shop/profile.py
"""
Shop Profile API (Customer authenticated)
Endpoints for managing customer profile in shop frontend.
Requires customer authentication.
"""
import logging
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.api.deps import get_current_customer_api
from app.core.database import get_db
from app.core.security import get_password_hash, verify_password
from app.exceptions import ValidationException
from models.database.customer import Customer
from models.schema.customer import (
CustomerPasswordChange,
CustomerResponse,
CustomerUpdate,
)
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/profile", response_model=CustomerResponse)
def get_profile(
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Get current customer profile.
Returns the authenticated customer's profile information.
"""
logger.debug(
f"[SHOP_API] get_profile for customer {customer.id}",
extra={
"customer_id": customer.id,
"email": customer.email,
},
)
return CustomerResponse.model_validate(customer)
@router.put("/profile", response_model=CustomerResponse)
def update_profile(
update_data: CustomerUpdate,
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Update current customer profile.
Allows updating profile fields like name, phone, marketing consent, etc.
Email changes require the new email to be unique within the vendor.
Request Body:
- email: New email address (optional)
- first_name: First name (optional)
- last_name: Last name (optional)
- phone: Phone number (optional)
- marketing_consent: Marketing consent (optional)
- preferred_language: Preferred language (optional)
"""
logger.debug(
f"[SHOP_API] update_profile for customer {customer.id}",
extra={
"customer_id": customer.id,
"email": customer.email,
"update_fields": [k for k, v in update_data.model_dump().items() if v is not None],
},
)
# If email is being changed, check uniqueness within vendor
if update_data.email and update_data.email != customer.email:
existing = (
db.query(Customer)
.filter(
Customer.vendor_id == customer.vendor_id,
Customer.email == update_data.email,
Customer.id != customer.id,
)
.first()
)
if existing:
raise ValidationException("Email already in use")
# Update only provided fields
update_dict = update_data.model_dump(exclude_unset=True)
for field, value in update_dict.items():
if value is not None:
setattr(customer, field, value)
db.commit()
db.refresh(customer)
logger.info(
f"Customer {customer.id} updated profile",
extra={
"customer_id": customer.id,
"updated_fields": list(update_dict.keys()),
},
)
return CustomerResponse.model_validate(customer)
@router.put("/profile/password", response_model=dict)
def change_password(
password_data: CustomerPasswordChange,
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Change customer password.
Requires current password verification and matching new password confirmation.
Request Body:
- current_password: Current password
- new_password: New password (min 8 chars, must contain letter and digit)
- confirm_password: Confirmation of new password
"""
logger.debug(
f"[SHOP_API] change_password for customer {customer.id}",
extra={
"customer_id": customer.id,
"email": customer.email,
},
)
# Verify current password
if not verify_password(password_data.current_password, customer.hashed_password):
raise ValidationException("Current password is incorrect")
# Verify passwords match
if password_data.new_password != password_data.confirm_password:
raise ValidationException("New passwords do not match")
# Check new password is different
if password_data.new_password == password_data.current_password:
raise ValidationException("New password must be different from current password")
# Update password
customer.hashed_password = get_password_hash(password_data.new_password)
db.commit()
logger.info(
f"Customer {customer.id} changed password",
extra={
"customer_id": customer.id,
"email": customer.email,
},
)
return {"message": "Password changed successfully"}

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.

View File

@@ -133,6 +133,7 @@
<!-- Modal Panel -->
<div x-show="showAddressModal"
@click.stop
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
@@ -286,6 +287,7 @@
<!-- Modal Panel -->
<div x-show="showDeleteModal"
@click.stop
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"

View File

@@ -309,6 +309,12 @@ function shopMessages() {
async loadConversations() {
try {
const token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
const params = new URLSearchParams({
skip: (this.currentPage - 1) * this.limit,
limit: this.limit,
@@ -317,8 +323,20 @@ function shopMessages() {
params.append('status', this.statusFilter);
}
const response = await fetch(`/api/v1/shop/messages?${params}`);
if (!response.ok) throw new Error('Failed to load conversations');
const response = await fetch(`/api/v1/shop/messages?${params}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
if (response.status === 401) {
localStorage.removeItem('customer_token');
localStorage.removeItem('customer_user');
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
throw new Error('Failed to load conversations');
}
const data = await response.json();
this.conversations = data.conversations;
@@ -334,7 +352,17 @@ function shopMessages() {
async selectConversation(conversationId) {
try {
const response = await fetch(`/api/v1/shop/messages/${conversationId}`);
const token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
const response = await fetch(`/api/v1/shop/messages/${conversationId}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) throw new Error('Failed to load conversation');
this.selectedConversation = await response.json();
@@ -360,7 +388,14 @@ function shopMessages() {
if (!this.selectedConversation) return;
try {
const response = await fetch(`/api/v1/shop/messages/${this.selectedConversation.id}`);
const token = localStorage.getItem('customer_token');
if (!token) return;
const response = await fetch(`/api/v1/shop/messages/${this.selectedConversation.id}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) return;
const data = await response.json();
@@ -397,6 +432,12 @@ function shopMessages() {
this.sending = true;
try {
const token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
const formData = new FormData();
formData.append('content', this.replyContent);
for (const file of this.attachments) {
@@ -405,6 +446,9 @@ function shopMessages() {
const response = await fetch(`/api/v1/shop/messages/${this.selectedConversation.id}/messages`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
},
body: formData,
});

View File

@@ -66,6 +66,97 @@
</span>
</div>
<!-- Order Tracking Timeline -->
<div class="mb-8 bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-6">Order Progress</h2>
<div class="relative">
<!-- Timeline Line -->
<div class="absolute left-4 top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700"></div>
<!-- Timeline Steps -->
<div class="space-y-6">
<!-- Pending -->
<div class="relative flex items-start">
<div class="flex items-center justify-center w-8 h-8 rounded-full shrink-0 z-10"
:class="getTimelineStepClass('pending')">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-4">
<p class="font-medium text-gray-900 dark:text-white">Order Placed</p>
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="formatDateTime(order.order_date || order.created_at)"></p>
</div>
</div>
<!-- Processing -->
<div class="relative flex items-start">
<div class="flex items-center justify-center w-8 h-8 rounded-full shrink-0 z-10"
:class="getTimelineStepClass('processing')">
<svg x-show="isStepComplete('processing')" class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
<svg x-show="!isStepComplete('processing')" class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-4">
<p class="font-medium" :class="isStepComplete('processing') ? 'text-gray-900 dark:text-white' : 'text-gray-400 dark:text-gray-500'">Processing</p>
<p class="text-sm text-gray-500 dark:text-gray-400" x-show="order.confirmed_at" x-text="formatDateTime(order.confirmed_at)"></p>
</div>
</div>
<!-- Shipped -->
<div class="relative flex items-start">
<div class="flex items-center justify-center w-8 h-8 rounded-full shrink-0 z-10"
:class="getTimelineStepClass('shipped')">
<svg x-show="isStepComplete('shipped')" class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
<svg x-show="!isStepComplete('shipped')" class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M8 16.5a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0zM15 16.5a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0z" />
<path d="M3 4a1 1 0 00-1 1v10a1 1 0 001 1h1.05a2.5 2.5 0 014.9 0H10a1 1 0 001-1V5a1 1 0 00-1-1H3zM14 7a1 1 0 00-1 1v6.05A2.5 2.5 0 0115.95 16H17a1 1 0 001-1v-5a1 1 0 00-.293-.707l-2-2A1 1 0 0015 7h-1z" />
</svg>
</div>
<div class="ml-4">
<p class="font-medium" :class="isStepComplete('shipped') ? 'text-gray-900 dark:text-white' : 'text-gray-400 dark:text-gray-500'">Shipped</p>
<p class="text-sm text-gray-500 dark:text-gray-400" x-show="order.shipped_at" x-text="formatDateTime(order.shipped_at)"></p>
</div>
</div>
<!-- Delivered -->
<div class="relative flex items-start">
<div class="flex items-center justify-center w-8 h-8 rounded-full shrink-0 z-10"
:class="getTimelineStepClass('delivered')">
<svg x-show="isStepComplete('delivered')" class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
<svg x-show="!isStepComplete('delivered')" class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-4">
<p class="font-medium" :class="isStepComplete('delivered') ? 'text-gray-900 dark:text-white' : 'text-gray-400 dark:text-gray-500'">Delivered</p>
<p class="text-sm text-gray-500 dark:text-gray-400" x-show="order.delivered_at" x-text="formatDateTime(order.delivered_at)"></p>
</div>
</div>
</div>
<!-- Cancelled/Refunded Notice -->
<div x-show="order.status === 'cancelled' || order.status === 'refunded'"
class="mt-6 p-4 rounded-lg"
:class="order.status === 'cancelled' ? 'bg-red-50 dark:bg-red-900/20' : 'bg-gray-50 dark:bg-gray-700'">
<p class="text-sm font-medium"
:class="order.status === 'cancelled' ? 'text-red-800 dark:text-red-200' : 'text-gray-800 dark:text-gray-200'"
x-text="order.status === 'cancelled' ? 'This order was cancelled' : 'This order was refunded'"></p>
<p class="text-sm mt-1"
:class="order.status === 'cancelled' ? 'text-red-600 dark:text-red-300' : 'text-gray-600 dark:text-gray-400'"
x-show="order.cancelled_at"
x-text="'on ' + formatDateTime(order.cancelled_at)"></p>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Main Content (Left Column - 2/3) -->
<div class="lg:col-span-2 space-y-6">
@@ -171,10 +262,21 @@
<dt class="text-gray-500 dark:text-gray-400">Shipping</dt>
<dd class="font-medium text-gray-900 dark:text-white" x-text="formatPrice(order.shipping_amount)"></dd>
</div>
<div x-show="order.tax_amount > 0" class="flex justify-between text-sm">
<dt class="text-gray-500 dark:text-gray-400">Tax</dt>
<!-- VAT Breakdown -->
<div x-show="order.tax_amount > 0 || order.vat_rate_label" class="flex justify-between text-sm">
<dt class="text-gray-500 dark:text-gray-400">
<span x-text="order.vat_rate_label || 'Tax'"></span>
<span x-show="order.vat_rate" class="text-xs ml-1">(<span x-text="order.vat_rate"></span>%)</span>
</dt>
<dd class="font-medium text-gray-900 dark:text-white" x-text="formatPrice(order.tax_amount)"></dd>
</div>
<!-- VAT Regime Info (for special cases) -->
<div x-show="order.vat_regime === 'reverse_charge'" class="text-xs text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-700/50 rounded p-2">
VAT Reverse Charge applies (B2B transaction)
</div>
<div x-show="order.vat_regime === 'exempt'" class="text-xs text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-700/50 rounded p-2">
VAT Exempt (Non-EU destination)
</div>
<div x-show="order.discount_amount > 0" class="flex justify-between text-sm">
<dt class="text-gray-500 dark:text-gray-400">Discount</dt>
<dd class="font-medium text-green-600 dark:text-green-400" x-text="'-' + formatPrice(order.discount_amount)"></dd>
@@ -186,6 +288,31 @@
</dl>
</div>
<!-- Invoice Download -->
<div x-show="canDownloadInvoice()" class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
<svg class="h-5 w-5 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Invoice
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Download your invoice for this order.
</p>
<button @click="downloadInvoice()"
:disabled="downloadingInvoice"
class="w-full inline-flex justify-center items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
<svg x-show="!downloadingInvoice" class="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<svg x-show="downloadingInvoice" class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span x-text="downloadingInvoice ? 'Generating...' : 'Download Invoice'"></span>
</button>
</div>
<!-- Shipping Info -->
<div x-show="order.shipping_method || order.tracking_number" class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
@@ -199,10 +326,27 @@
<p class="text-gray-500 dark:text-gray-400">Method</p>
<p class="font-medium text-gray-900 dark:text-white" x-text="order.shipping_method"></p>
</div>
<div x-show="order.shipping_carrier">
<p class="text-gray-500 dark:text-gray-400">Carrier</p>
<p class="font-medium text-gray-900 dark:text-white" x-text="order.shipping_carrier"></p>
</div>
<div x-show="order.tracking_number">
<p class="text-gray-500 dark:text-gray-400">Tracking Number</p>
<p class="font-medium text-gray-900 dark:text-white" x-text="order.tracking_number"></p>
</div>
<!-- Track Package Button -->
<a x-show="order.tracking_url"
:href="order.tracking_url"
target="_blank"
rel="noopener noreferrer"
class="mt-3 w-full inline-flex justify-center items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white transition-colors"
style="background-color: var(--color-primary)">
<svg class="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Track Package
</a>
</div>
</div>
@@ -249,6 +393,7 @@ function shopOrderDetailPage() {
loading: true,
error: '',
orderId: {{ order_id }},
downloadingInvoice: false,
// Status mapping
statuses: {
@@ -262,6 +407,9 @@ function shopOrderDetailPage() {
refunded: { label: 'Refunded', class: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200' }
},
// Timeline step order for progress tracking
timelineSteps: ['pending', 'processing', 'shipped', 'delivered'],
async init() {
await this.loadOrder();
},
@@ -271,9 +419,25 @@ function shopOrderDetailPage() {
this.error = '';
try {
const response = await fetch(`/api/v1/shop/orders/${this.orderId}`);
const token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
const response = await fetch(`/api/v1/shop/orders/${this.orderId}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
if (response.status === 401) {
localStorage.removeItem('customer_token');
localStorage.removeItem('customer_user');
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
if (response.status === 404) {
throw new Error('Order not found');
}
@@ -315,6 +479,131 @@ function shopOrderDetailPage() {
hour: '2-digit',
minute: '2-digit'
});
},
// ===== Timeline Functions =====
/**
* Get the current step index in the order flow
*/
getCurrentStepIndex() {
if (!this.order) return 0;
const status = this.order.status;
// Handle special statuses
if (status === 'cancelled' || status === 'refunded') {
return -1; // Special case
}
if (status === 'completed') {
return 4; // All steps complete
}
if (status === 'partially_shipped') {
return 2; // Between processing and shipped
}
return this.timelineSteps.indexOf(status) + 1;
},
/**
* Check if a timeline step is complete
*/
isStepComplete(step) {
if (!this.order) return false;
const currentIndex = this.getCurrentStepIndex();
const stepIndex = this.timelineSteps.indexOf(step) + 1;
return currentIndex >= stepIndex;
},
/**
* Get CSS classes for a timeline step
*/
getTimelineStepClass(step) {
if (this.isStepComplete(step)) {
// Completed step - green
return 'bg-green-500 text-white';
} else if (this.order && this.timelineSteps.indexOf(this.order.status) === this.timelineSteps.indexOf(step)) {
// Current step - primary color with pulse
return 'bg-blue-500 text-white animate-pulse';
} else {
// Future step - gray
return 'bg-gray-200 dark:bg-gray-600 text-gray-400 dark:text-gray-500';
}
},
// ===== Invoice Functions =====
/**
* Check if invoice can be downloaded (order must be at least processing)
*/
canDownloadInvoice() {
if (!this.order) return false;
const invoiceStatuses = ['processing', 'partially_shipped', 'shipped', 'delivered', 'completed'];
return invoiceStatuses.includes(this.order.status);
},
/**
* Download invoice PDF for this order
*/
async downloadInvoice() {
if (this.downloadingInvoice) return;
this.downloadingInvoice = true;
try {
const token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
const response = await fetch(`/api/v1/shop/orders/${this.orderId}/invoice`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
if (response.status === 401) {
localStorage.removeItem('customer_token');
localStorage.removeItem('customer_user');
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
if (response.status === 404) {
throw new Error('Invoice not yet available. Please try again later.');
}
throw new Error('Failed to download invoice');
}
// Get filename from Content-Disposition header if available
const contentDisposition = response.headers.get('Content-Disposition');
let filename = `invoice-${this.order.order_number}.pdf`;
if (contentDisposition) {
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
if (match && match[1]) {
filename = match[1].replace(/['"]/g, '');
}
}
// Download the blob
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch (err) {
console.error('Error downloading invoice:', err);
alert(err.message || 'Failed to download invoice');
} finally {
this.downloadingInvoice = false;
}
}
}
}

View File

@@ -173,10 +173,26 @@ function shopOrdersPage() {
this.error = '';
try {
const token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
const skip = (page - 1) * this.perPage;
const response = await fetch(`/api/v1/shop/orders?skip=${skip}&limit=${this.perPage}`);
const response = await fetch(`/api/v1/shop/orders?skip=${skip}&limit=${this.perPage}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
if (response.status === 401) {
localStorage.removeItem('customer_token');
localStorage.removeItem('customer_user');
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
throw new Error('Failed to load orders');
}

View File

@@ -1,15 +1,545 @@
{# app/templates/shop/account/profile.html #}
{% extends "shop/base.html" %}
{% block title %}My Profile{% endblock %}
{% block title %}My Profile - {{ vendor.name }}{% endblock %}
{% block alpine_data %}shopProfilePage(){% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-8">My Profile</h1>
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Breadcrumb -->
<nav class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400">
<li>
<a href="{{ base_url }}shop/account/dashboard" class="hover:text-primary">My Account</a>
</li>
<li class="flex items-center">
<svg class="h-4 w-4 mx-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
<span class="text-gray-900 dark:text-white">Profile</span>
</li>
</ol>
</nav>
{# TODO: Implement profile management #}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p class="text-gray-600 dark:text-gray-400">Profile management coming soon...</p>
<!-- Page Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">My Profile</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400">Manage your account information and preferences</p>
</div>
<!-- Loading State -->
<div x-show="loading" class="flex justify-center items-center py-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary" style="border-color: var(--color-primary)"></div>
</div>
<!-- Error State -->
<div x-show="error && !loading" class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6">
<div class="flex">
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
<p class="ml-3 text-sm text-red-700 dark:text-red-300" x-text="error"></p>
</div>
</div>
<!-- Success Message -->
<div x-show="successMessage"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 transform -translate-y-2"
x-transition:enter-end="opacity-100 transform translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 transform translate-y-0"
x-transition:leave-end="opacity-0 transform -translate-y-2"
class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 mb-6">
<div class="flex">
<svg class="h-5 w-5 text-green-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
<p class="ml-3 text-sm text-green-700 dark:text-green-300" x-text="successMessage"></p>
</div>
</div>
<div x-show="!loading" class="space-y-8">
<!-- Profile Information Section -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Profile Information</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">Update your personal details</p>
</div>
<form @submit.prevent="saveProfile" class="p-6 space-y-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<!-- First Name -->
<div>
<label for="first_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
First Name <span class="text-red-500">*</span>
</label>
<input type="text" id="first_name" x-model="profileForm.first_name" required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
focus:ring-2 focus:ring-primary focus:border-transparent
dark:bg-gray-700 dark:text-white"
style="--tw-ring-color: var(--color-primary)">
</div>
<!-- Last Name -->
<div>
<label for="last_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Last Name <span class="text-red-500">*</span>
</label>
<input type="text" id="last_name" x-model="profileForm.last_name" required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
focus:ring-2 focus:ring-primary focus:border-transparent
dark:bg-gray-700 dark:text-white"
style="--tw-ring-color: var(--color-primary)">
</div>
</div>
<!-- Email -->
<div>
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email Address <span class="text-red-500">*</span>
</label>
<input type="email" id="email" x-model="profileForm.email" required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
focus:ring-2 focus:ring-primary focus:border-transparent
dark:bg-gray-700 dark:text-white"
style="--tw-ring-color: var(--color-primary)">
</div>
<!-- Phone -->
<div>
<label for="phone" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Phone Number
</label>
<input type="tel" id="phone" x-model="profileForm.phone"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
focus:ring-2 focus:ring-primary focus:border-transparent
dark:bg-gray-700 dark:text-white"
style="--tw-ring-color: var(--color-primary)">
</div>
<!-- Submit Button -->
<div class="flex justify-end">
<button type="submit"
:disabled="savingProfile"
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm
text-sm font-medium text-white bg-primary hover:bg-primary-dark
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
style="background-color: var(--color-primary)">
<svg x-show="savingProfile" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span x-text="savingProfile ? 'Saving...' : 'Save Changes'"></span>
</button>
</div>
</form>
</div>
<!-- Preferences Section -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Preferences</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">Manage your account preferences</p>
</div>
<form @submit.prevent="savePreferences" class="p-6 space-y-6">
<!-- Language -->
<div>
<label for="language" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Preferred Language
</label>
<select id="language" x-model="preferencesForm.preferred_language"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
focus:ring-2 focus:ring-primary focus:border-transparent
dark:bg-gray-700 dark:text-white"
style="--tw-ring-color: var(--color-primary)">
<option value="">Use shop default</option>
<option value="en">English</option>
<option value="fr">Francais</option>
<option value="de">Deutsch</option>
<option value="lb">Letzebuergesch</option>
</select>
</div>
<!-- Marketing Consent -->
<div class="flex items-start">
<div class="flex items-center h-5">
<input type="checkbox" id="marketing_consent" x-model="preferencesForm.marketing_consent"
class="h-4 w-4 rounded border-gray-300 dark:border-gray-600
focus:ring-2 focus:ring-primary
dark:bg-gray-700"
style="color: var(--color-primary)">
</div>
<div class="ml-3">
<label for="marketing_consent" class="text-sm font-medium text-gray-700 dark:text-gray-300">
Marketing Communications
</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
Receive emails about new products, offers, and promotions
</p>
</div>
</div>
<!-- Submit Button -->
<div class="flex justify-end">
<button type="submit"
:disabled="savingPreferences"
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm
text-sm font-medium text-white bg-primary hover:bg-primary-dark
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
style="background-color: var(--color-primary)">
<svg x-show="savingPreferences" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span x-text="savingPreferences ? 'Saving...' : 'Save Preferences'"></span>
</button>
</div>
</form>
</div>
<!-- Change Password Section -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Change Password</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">Update your account password</p>
</div>
<form @submit.prevent="changePassword" class="p-6 space-y-6">
<!-- Current Password -->
<div>
<label for="current_password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Current Password <span class="text-red-500">*</span>
</label>
<input type="password" id="current_password" x-model="passwordForm.current_password" required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
focus:ring-2 focus:ring-primary focus:border-transparent
dark:bg-gray-700 dark:text-white"
style="--tw-ring-color: var(--color-primary)">
</div>
<!-- New Password -->
<div>
<label for="new_password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
New Password <span class="text-red-500">*</span>
</label>
<input type="password" id="new_password" x-model="passwordForm.new_password" required
minlength="8"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
focus:ring-2 focus:ring-primary focus:border-transparent
dark:bg-gray-700 dark:text-white"
style="--tw-ring-color: var(--color-primary)">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Must be at least 8 characters with at least one letter and one number
</p>
</div>
<!-- Confirm Password -->
<div>
<label for="confirm_password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Confirm New Password <span class="text-red-500">*</span>
</label>
<input type="password" id="confirm_password" x-model="passwordForm.confirm_password" required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
focus:ring-2 focus:ring-primary focus:border-transparent
dark:bg-gray-700 dark:text-white"
style="--tw-ring-color: var(--color-primary)">
<p x-show="passwordForm.confirm_password && passwordForm.new_password !== passwordForm.confirm_password"
class="mt-1 text-xs text-red-500">
Passwords do not match
</p>
</div>
<!-- Password Error -->
<div x-show="passwordError" class="text-sm text-red-600 dark:text-red-400" x-text="passwordError"></div>
<!-- Submit Button -->
<div class="flex justify-end">
<button type="submit"
:disabled="changingPassword || passwordForm.new_password !== passwordForm.confirm_password"
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm
text-sm font-medium text-white bg-primary hover:bg-primary-dark
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary
disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
style="background-color: var(--color-primary)">
<svg x-show="changingPassword" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span x-text="changingPassword ? 'Changing...' : 'Change Password'"></span>
</button>
</div>
</form>
</div>
<!-- Account Info (read-only) -->
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-4">Account Information</h3>
<dl class="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div>
<dt class="text-gray-500 dark:text-gray-400">Customer Number</dt>
<dd class="mt-1 text-gray-900 dark:text-white font-medium" x-text="profile?.customer_number || '-'"></dd>
</div>
<div>
<dt class="text-gray-500 dark:text-gray-400">Member Since</dt>
<dd class="mt-1 text-gray-900 dark:text-white font-medium" x-text="formatDate(profile?.created_at)"></dd>
</div>
<div>
<dt class="text-gray-500 dark:text-gray-400">Total Orders</dt>
<dd class="mt-1 text-gray-900 dark:text-white font-medium" x-text="profile?.total_orders || 0"></dd>
</div>
<div>
<dt class="text-gray-500 dark:text-gray-400">Total Spent</dt>
<dd class="mt-1 text-gray-900 dark:text-white font-medium" x-text="formatPrice(profile?.total_spent)"></dd>
</div>
</dl>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function shopProfilePage() {
return {
...shopLayoutData(),
// State
profile: null,
loading: true,
error: '',
successMessage: '',
// Forms
profileForm: {
first_name: '',
last_name: '',
email: '',
phone: ''
},
preferencesForm: {
preferred_language: '',
marketing_consent: false
},
passwordForm: {
current_password: '',
new_password: '',
confirm_password: ''
},
// Form states
savingProfile: false,
savingPreferences: false,
changingPassword: false,
passwordError: '',
async init() {
await this.loadProfile();
},
async loadProfile() {
this.loading = true;
this.error = '';
try {
const token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
const response = await fetch('/api/v1/shop/profile', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
if (response.status === 401) {
localStorage.removeItem('customer_token');
localStorage.removeItem('customer_user');
window.location.href = '{{ base_url }}shop/account/login?next=' + encodeURIComponent(window.location.pathname);
return;
}
throw new Error('Failed to load profile');
}
this.profile = await response.json();
// Populate forms
this.profileForm = {
first_name: this.profile.first_name || '',
last_name: this.profile.last_name || '',
email: this.profile.email || '',
phone: this.profile.phone || ''
};
this.preferencesForm = {
preferred_language: this.profile.preferred_language || '',
marketing_consent: this.profile.marketing_consent || false
};
} catch (err) {
console.error('Error loading profile:', err);
this.error = err.message || 'Failed to load profile';
} finally {
this.loading = false;
}
},
async saveProfile() {
this.savingProfile = true;
this.error = '';
this.successMessage = '';
try {
const token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}shop/account/login';
return;
}
const response = await fetch('/api/v1/shop/profile', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(this.profileForm)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to save profile');
}
this.profile = await response.json();
this.successMessage = 'Profile updated successfully';
// Update localStorage user data
const userStr = localStorage.getItem('customer_user');
if (userStr) {
const user = JSON.parse(userStr);
user.first_name = this.profile.first_name;
user.last_name = this.profile.last_name;
user.email = this.profile.email;
localStorage.setItem('customer_user', JSON.stringify(user));
}
setTimeout(() => this.successMessage = '', 5000);
} catch (err) {
console.error('Error saving profile:', err);
this.error = err.message || 'Failed to save profile';
} finally {
this.savingProfile = false;
}
},
async savePreferences() {
this.savingPreferences = true;
this.error = '';
this.successMessage = '';
try {
const token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}shop/account/login';
return;
}
const response = await fetch('/api/v1/shop/profile', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(this.preferencesForm)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to save preferences');
}
this.profile = await response.json();
this.successMessage = 'Preferences updated successfully';
setTimeout(() => this.successMessage = '', 5000);
} catch (err) {
console.error('Error saving preferences:', err);
this.error = err.message || 'Failed to save preferences';
} finally {
this.savingPreferences = false;
}
},
async changePassword() {
if (this.passwordForm.new_password !== this.passwordForm.confirm_password) {
this.passwordError = 'Passwords do not match';
return;
}
this.changingPassword = true;
this.passwordError = '';
this.successMessage = '';
try {
const token = localStorage.getItem('customer_token');
if (!token) {
window.location.href = '{{ base_url }}shop/account/login';
return;
}
const response = await fetch('/api/v1/shop/profile/password', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(this.passwordForm)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to change password');
}
// Clear password form
this.passwordForm = {
current_password: '',
new_password: '',
confirm_password: ''
};
this.successMessage = 'Password changed successfully';
setTimeout(() => this.successMessage = '', 5000);
} catch (err) {
console.error('Error changing password:', err);
this.passwordError = err.message || 'Failed to change password';
} finally {
this.changingPassword = false;
}
},
formatPrice(amount) {
if (!amount && amount !== 0) return '-';
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
}).format(amount);
},
formatDate(dateStr) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
}
}
</script>
{% endblock %}

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))