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

@@ -0,0 +1,72 @@
# alembic/versions/q5e6f7a8b9c0_add_vat_fields_to_orders.py
"""Add VAT fields to orders table.
Adds vat_regime, vat_rate, vat_rate_label, and vat_destination_country
to enable proper VAT tracking at order creation time, aligned with
invoice VAT logic.
Revision ID: q5e6f7a8b9c0
Revises: p4d5e6f7a8b9
Create Date: 2026-01-02 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'q5e6f7a8b9c0'
down_revision: Union[str, None] = 'p4d5e6f7a8b9'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add VAT regime (domestic, oss, reverse_charge, origin, exempt)
op.add_column(
'orders',
sa.Column('vat_regime', sa.String(20), nullable=True)
)
# Add VAT rate as percentage (e.g., 17.00 for 17%)
op.add_column(
'orders',
sa.Column('vat_rate', sa.Numeric(5, 2), nullable=True)
)
# Add human-readable VAT label (e.g., "Luxembourg VAT 17%")
op.add_column(
'orders',
sa.Column('vat_rate_label', sa.String(100), nullable=True)
)
# Add destination country for cross-border sales (ISO code)
op.add_column(
'orders',
sa.Column('vat_destination_country', sa.String(2), nullable=True)
)
# Populate VAT fields for existing orders based on shipping country
# Default to 'domestic' for LU orders and 'origin' for other EU orders
op.execute("""
UPDATE orders
SET vat_regime = CASE
WHEN ship_country_iso = 'LU' THEN 'domestic'
WHEN ship_country_iso IN ('AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'MT', 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE') THEN 'origin'
ELSE 'exempt'
END,
vat_destination_country = CASE
WHEN ship_country_iso != 'LU' AND ship_country_iso IN ('AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'MT', 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE') THEN ship_country_iso
ELSE NULL
END
WHERE vat_regime IS NULL
""")
def downgrade() -> None:
op.drop_column('orders', 'vat_destination_country')
op.drop_column('orders', 'vat_rate_label')
op.drop_column('orders', 'vat_rate')
op.drop_column('orders', 'vat_regime')

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

View File

@@ -0,0 +1,220 @@
# OMS Launch Readiness Analysis
This document tracks the launch readiness status of the Order Management System (OMS).
**Last Updated:** 2026-01-02
**Overall Status:** 95% Feature Complete
---
## Executive Summary
The OMS is nearly production ready with core order processing, invoicing, inventory management, and customer account features complete. Recent updates have added customer profile management, multiple address support, and fixed frontend authentication across all shop account pages.
---
## Feature Completion Status
### Core Order Processing (95% Complete)
| Feature | Status | Notes |
|---------|--------|-------|
| Order creation from shop | Complete | Full checkout flow |
| Order list/detail (vendor) | Complete | With filtering, search |
| Order status management | Complete | Full status workflow |
| Customer stats update | Complete | Updates on order placement |
| Cart clearing on order | Complete | Clears after successful order |
| Order confirmation email | Complete | Template-based emails |
| VAT calculation | Complete | Aligned with invoice logic |
### Invoicing System (95% Complete)
| Feature | Status | Notes |
|---------|--------|-------|
| Invoice generation | Complete | Auto from orders |
| PDF generation | Complete | WeasyPrint |
| Invoice settings | Complete | Per-vendor config |
| VAT regimes | Complete | Domestic, OSS, reverse charge, exempt |
| Customer invoice download | Complete | Shop order detail page |
### Shop Frontend (95% Complete)
| Feature | Status | Notes |
|---------|--------|-------|
| Order history | Complete | Customer order list |
| Order detail page | Complete | With VAT breakdown, timeline |
| Invoice download | Complete | PDF download button |
| Order tracking timeline | Complete | Visual status progression |
| Product catalog | Complete | With search, filters |
| Shopping cart | Complete | Full cart functionality |
| Checkout flow | Complete | Address, payment selection |
| Customer registration | Complete | Email-based |
| Customer login | Complete | JWT authentication |
| Password reset | Partial | Email sending TODO |
| Customer profile | Complete | Full profile management |
| Customer addresses | Complete | Multiple addresses, address book |
| Customer messages | Complete | Conversation-based messaging |
### Vendor Dashboard (90% Complete)
| Feature | Status | Notes |
|---------|--------|-------|
| Order management | Complete | List, detail, status |
| Invoice management | Complete | Create, view, PDF |
| Product management | Complete | CRUD, images |
| Inventory management | Complete | Stock levels |
| Customer list | Complete | View, filter |
| Analytics | Partial | Basic stats |
| Shipping integration | Partial | Manual only |
| Returns/refunds | Not Started | Future feature |
### Admin Dashboard (90% Complete)
| Feature | Status | Notes |
|---------|--------|-------|
| Vendor management | Complete | Full CRUD |
| User management | Complete | Roles, permissions |
| Order overview | Complete | Cross-vendor view |
| System settings | Complete | Configuration |
| Marketplace sync | Complete | LetzShop integration |
| Code quality dashboard | Complete | Validation results |
---
## Recent Completions (January 2026)
### Customer Account Features
- **Profile Management**: Full customer profile API and UI
- Profile information editing (name, email, phone)
- Preferences management (language, marketing consent)
- Password change functionality
- API: `GET/PUT /api/v1/shop/profile`, `PUT /api/v1/shop/profile/password`
- **Multiple Addresses**: Complete address book functionality
- Add, edit, delete addresses
- Set default shipping/billing addresses
- Address type support (shipping, billing)
- Country ISO codes for VAT calculation
- API: Full CRUD at `/api/v1/shop/addresses`
- **Frontend Authentication**: Fixed all shop account pages
- Orders, messages, addresses, profile now properly authenticated
- Token-based API calls with Authorization header
- Proper redirect to login on 401 responses
### VAT Calculation Alignment
- Created shared VAT utility (`app/utils/vat.py`)
- Added VAT fields to Order model: `vat_regime`, `vat_rate`, `vat_rate_label`, `vat_destination_country`
- Updated order service to use vendor invoice settings
- Full support for EU VAT regimes:
- Domestic (same country)
- OSS (One-Stop-Shop for B2C cross-border)
- Reverse charge (B2B with VAT number)
- Origin (non-OSS cross-border)
- Exempt (non-EU sales)
### Shop Order Detail Enhancements
- Order tracking timeline with visual status progression
- Invoice download button with auto-generation
- VAT breakdown display in order summary
- New API endpoint: `GET /api/v1/shop/orders/{order_id}/invoice`
### Test Coverage
- Unit tests for VAT utility (23 tests)
- Integration tests for shop orders API (13 tests)
- Integration tests for shop addresses API
- All validation scripts fixed and passing
---
## Remaining Gaps
### High Priority
1. **Payment Verification** - Payment gateway integration for order completion verification
2. **Email Notification System** - Password reset, order updates, shipping notifications
### Medium Priority
1. **Analytics Enhancement** - Sales reports, revenue tracking
2. **Shipping Label Generation** - Integration with shipping providers
3. **Multi-language Support** - Full i18n for all templates
### Low Priority
1. **Returns/Refunds** - Return requests, refund processing
2. **Promotions/Discounts** - Coupon codes, promotional pricing
3. **Wishlist** - Customer product wishlist
---
## Frontend Gap Summary
### Admin Dashboard Gaps
| Component | Status | Priority |
|-----------|--------|----------|
| Dashboard analytics | Partial | Medium |
| Bulk operations | Missing | Low |
| Export functionality | Missing | Low |
| Activity logs | Partial | Low |
### Vendor Dashboard Gaps
| Component | Status | Priority |
|-----------|--------|----------|
| Revenue analytics | Missing | Medium |
| Shipping automation | Missing | Medium |
| Return management | Missing | Low |
| Promotion management | Missing | Low |
| Multi-warehouse | Missing | Low |
### Shop Frontend Gaps
| Component | Status | Priority |
|-----------|--------|----------|
| Password reset email | Missing | High |
| Order tracking updates | Missing | Medium |
| Wishlist | Missing | Low |
| Reviews/ratings | Missing | Low |
| Social login | Missing | Low |
---
## Validation Status
All code validators pass:
```
Architecture Validator: PASSED
Security Validator: PASSED (with skips)
Performance Validator: PASSED (with skips)
```
---
## Launch Checklist
- [x] Core order processing
- [x] Invoice generation
- [x] VAT calculation
- [x] Customer authentication
- [x] Order confirmation emails
- [x] Shop order detail page
- [x] Invoice download
- [x] Customer profile management
- [x] Multi-address support
- [x] Customer messaging
- [ ] Payment verification integration
- [ ] Password reset emails
- [ ] Production deployment configuration
- [ ] SSL certificates
- [ ] Database backups
- [ ] Monitoring setup
---
## Recommendations
1. **Immediate Focus:** Payment verification for order completion
2. **Short-term:** Email notifications for password reset and order updates
3. **Pre-launch:** Production infrastructure setup (SSL, monitoring, backups)
4. **Post-launch:** Analytics enhancements and shipping integrations

View File

@@ -179,6 +179,7 @@ nav:
# --- Deployment & Operations ---
- Deployment:
- Overview: deployment/index.md
- Launch Readiness: deployment/launch-readiness.md
- Docker: deployment/docker.md
- Production: deployment/production.md
- GitLab CI/CD: deployment/gitlab.md

View File

@@ -23,6 +23,7 @@ from sqlalchemy import (
ForeignKey,
Index,
Integer,
Numeric,
String,
Text,
)
@@ -89,6 +90,16 @@ class Order(Base, TimestampMixin):
total_amount_cents = Column(Integer, nullable=False)
currency = Column(String(10), default="EUR")
# === VAT Information ===
# VAT regime: domestic, oss, reverse_charge, origin, exempt
vat_regime = Column(String(20), nullable=True)
# VAT rate as percentage (e.g., 17.00 for 17%)
vat_rate = Column(Numeric(5, 2), nullable=True)
# Human-readable VAT label (e.g., "Luxembourg VAT 17%")
vat_rate_label = Column(String(100), nullable=True)
# Destination country for cross-border sales (ISO code)
vat_destination_country = Column(String(2), nullable=True)
# === Customer Snapshot (preserved at order time) ===
customer_first_name = Column(String(100), nullable=False)
customer_last_name = Column(String(100), nullable=False)

View File

@@ -66,6 +66,28 @@ class CustomerUpdate(BaseModel):
return v.lower() if v else None
class CustomerPasswordChange(BaseModel):
"""Schema for customer password change."""
current_password: str = Field(..., description="Current password")
new_password: str = Field(
..., min_length=8, description="New password (minimum 8 characters)"
)
confirm_password: str = Field(..., description="Confirm new password")
@field_validator("new_password")
@classmethod
def password_strength(cls, v: str) -> str:
"""Validate password strength."""
if len(v) < 8:
raise ValueError("Password must be at least 8 characters")
if not any(char.isdigit() for char in v):
raise ValueError("Password must contain at least one digit")
if not any(char.isalpha() for char in v):
raise ValueError("Password must contain at least one letter")
return v
# ============================================================================
# Customer Response
# ============================================================================

View File

@@ -239,6 +239,12 @@ class OrderResponse(BaseModel):
total_amount: float
currency: str
# VAT information
vat_regime: str | None = None
vat_rate: float | None = None
vat_rate_label: str | None = None
vat_destination_country: str | None = None
# Customer snapshot
customer_first_name: str
customer_last_name: str
@@ -400,6 +406,12 @@ class AdminOrderItem(BaseModel):
total_amount: float
currency: str
# VAT information
vat_regime: str | None = None
vat_rate: float | None = None
vat_rate_label: str | None = None
vat_destination_country: str | None = None
# Shipping
ship_country_iso: str
tracking_number: str | None

View File

@@ -5,21 +5,78 @@ Shared functionality for all validators.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any
import yaml
class Severity(str, Enum):
"""Severity levels for validation findings."""
ERROR = "error"
WARNING = "warning"
INFO = "info"
@dataclass
class Violation:
"""A single validation violation."""
rule_id: str
message: str
severity: Severity
file_path: str = ""
line: int = 0
suggestion: str = ""
@dataclass
class ValidationResult:
"""Result of a validation run."""
violations: list[Violation] = field(default_factory=list)
files_checked: int = 0
def has_errors(self) -> bool:
"""Check if there are any error-level violations."""
return any(v.severity == Severity.ERROR for v in self.violations)
def error_count(self) -> int:
"""Count error-level violations."""
return sum(1 for v in self.violations if v.severity == Severity.ERROR)
def warning_count(self) -> int:
"""Count warning-level violations."""
return sum(1 for v in self.violations if v.severity == Severity.WARNING)
def info_count(self) -> int:
"""Count info-level violations."""
return sum(1 for v in self.violations if v.severity == Severity.INFO)
class BaseValidator(ABC):
"""Base class for architecture, security, and performance validators."""
def __init__(self, rules_dir: str, project_root: Path | None = None):
# Directories/patterns to ignore by default
IGNORE_PATTERNS = [
".venv", "venv", "node_modules", "__pycache__", ".git",
".pytest_cache", ".mypy_cache", "dist", "build", "*.egg-info",
"migrations", "alembic/versions", ".tox", "htmlcov",
]
def __init__(
self,
rules_dir: str = "",
project_root: Path | None = None,
verbose: bool = False,
):
self.rules_dir = rules_dir
self.project_root = project_root or Path.cwd()
self.verbose = verbose
self.rules: list[dict[str, Any]] = []
self.errors: list[dict[str, Any]] = []
self.warnings: list[dict[str, Any]] = []
self.result = ValidationResult()
def load_rules(self) -> None:
"""Load rules from YAML files."""
@@ -37,9 +94,17 @@ class BaseValidator(ABC):
if data and "rules" in data:
self.rules.extend(data["rules"])
@abstractmethod
def validate(self) -> bool:
"""Run validation. Returns True if passed."""
"""Run validation. Returns True if passed.
Subclasses should implement validate_all() instead.
"""
result = self.validate_all()
return not result.has_errors() if hasattr(result, 'has_errors') else True
def validate_all(self, target_path: Path | None = None) -> ValidationResult:
"""Run all validations. Override in subclasses."""
return ValidationResult()
def add_error(
self, rule_id: str, message: str, file: str = "", line: int = 0
@@ -109,3 +174,117 @@ class BaseValidator(ABC):
passed = self.validate()
self.print_results()
return 0 if passed else 1
def _should_ignore_file(self, file_path: Path) -> bool:
"""Check if a file should be ignored based on patterns."""
path_str = str(file_path)
for pattern in self.IGNORE_PATTERNS:
if pattern in path_str:
return True
return False
def _add_violation(
self,
rule_id: str,
rule_name: str,
severity: Severity,
file_path: Path,
line_number: int,
message: str,
context: str = "",
suggestion: str = "",
) -> None:
"""Add a violation to the result."""
violation = Violation(
rule_id=rule_id,
message=f"{rule_name}: {message}",
severity=severity,
file_path=str(file_path),
line=line_number,
suggestion=suggestion,
)
self.result.violations.append(violation)
if self.verbose and context:
print(f" [{rule_id}] {file_path}:{line_number}")
print(f" {message}")
print(f" Context: {context}")
def validate_file(self, file_path: Path) -> ValidationResult:
"""Validate a single file."""
if not file_path.exists():
print(f"File not found: {file_path}")
return self.result
self.result.files_checked = 1
content = file_path.read_text()
lines = content.split("\n")
self._validate_file_content(file_path, content, lines)
return self.result
def _validate_file_content(self, file_path: Path, content: str, lines: list[str]):
"""Validate file content. Override in subclasses."""
pass
def output_results(self, json_output: bool = False, errors_only: bool = False) -> None:
"""Output validation results."""
if json_output:
import json
output = {
"files_checked": self.result.files_checked,
"violations": [
{
"rule_id": v.rule_id,
"message": v.message,
"severity": v.severity.value,
"file": v.file_path,
"line": v.line,
"suggestion": v.suggestion,
}
for v in self.result.violations
if not errors_only or v.severity == Severity.ERROR
],
}
print(json.dumps(output, indent=2))
else:
self._print_violations(errors_only)
def _print_violations(self, errors_only: bool = False) -> None:
"""Print violations in human-readable format."""
violations = self.result.violations
if errors_only:
violations = [v for v in violations if v.severity == Severity.ERROR]
if not violations:
print(f"\n✅ No issues found! ({self.result.files_checked} files checked)")
return
errors = [v for v in violations if v.severity == Severity.ERROR]
warnings = [v for v in violations if v.severity == Severity.WARNING]
info = [v for v in violations if v.severity == Severity.INFO]
if errors:
print(f"\n{len(errors)} errors:")
for v in errors:
print(f" [{v.rule_id}] {v.file_path}:{v.line}")
print(f" {v.message}")
if v.suggestion:
print(f" 💡 {v.suggestion}")
if warnings and not errors_only:
print(f"\n⚠️ {len(warnings)} warnings:")
for v in warnings:
print(f" [{v.rule_id}] {v.file_path}:{v.line}")
print(f" {v.message}")
if info and not errors_only:
print(f"\n {len(info)} info:")
for v in info:
print(f" [{v.rule_id}] {v.file_path}:{v.line}")
print(f" {v.message}")
print(f"\n📊 Summary: {len(errors)} errors, {len(warnings)} warnings, {len(info)} info")
def get_exit_code(self) -> int:
"""Get exit code based on validation results."""
return 1 if self.result.has_errors() else 0

View File

@@ -34,8 +34,6 @@ from pathlib import Path
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent))
from base_validator import Severity
def run_architecture_validator(verbose: bool = False) -> tuple[int, dict]:
"""Run the architecture validator"""

View File

@@ -197,7 +197,8 @@ function shopLayoutData() {
info: 'bg-blue-500'
};
toast.innerHTML = ` // noqa: SEC-015 - message is application-controlled
// noqa: SEC-015 - message is application-controlled
toast.innerHTML = `
<div class="${colors[type]} text-white px-6 py-3 rounded-lg shadow-lg flex items-center space-x-3">
<span>${message}</span>
<button onclick="this.parentElement.parentElement.remove()"

View File

@@ -0,0 +1,188 @@
# tests/unit/utils/test_vat.py
"""Tests for VAT calculation utilities."""
from decimal import Decimal
import pytest
from app.utils.vat import (
EU_VAT_RATES,
VATRegime,
VATResult,
calculate_vat_amount,
determine_vat_regime,
get_vat_rate_for_country,
get_vat_rate_label,
is_eu_country,
)
class TestGetVatRateForCountry:
"""Tests for get_vat_rate_for_country function."""
def test_luxembourg_rate(self):
"""Luxembourg should have 17% VAT."""
assert get_vat_rate_for_country("LU") == Decimal("17.00")
def test_germany_rate(self):
"""Germany should have 19% VAT."""
assert get_vat_rate_for_country("DE") == Decimal("19.00")
def test_france_rate(self):
"""France should have 20% VAT."""
assert get_vat_rate_for_country("FR") == Decimal("20.00")
def test_hungary_rate(self):
"""Hungary should have 27% VAT (highest in EU)."""
assert get_vat_rate_for_country("HU") == Decimal("27.00")
def test_non_eu_country(self):
"""Non-EU countries should have 0% VAT."""
assert get_vat_rate_for_country("US") == Decimal("0.00")
assert get_vat_rate_for_country("CH") == Decimal("0.00")
def test_lowercase_country_code(self):
"""Should handle lowercase country codes."""
assert get_vat_rate_for_country("lu") == Decimal("17.00")
def test_all_eu_countries_covered(self):
"""All 27 EU member states should be in the VAT rates."""
assert len(EU_VAT_RATES) == 27
class TestIsEuCountry:
"""Tests for is_eu_country function."""
def test_eu_countries(self):
"""EU countries should return True."""
assert is_eu_country("LU") is True
assert is_eu_country("DE") is True
assert is_eu_country("FR") is True
def test_non_eu_countries(self):
"""Non-EU countries should return False."""
assert is_eu_country("US") is False
assert is_eu_country("CH") is False
assert is_eu_country("UK") is False
class TestGetVatRateLabel:
"""Tests for get_vat_rate_label function."""
def test_luxembourg_label(self):
"""Luxembourg label should be formatted correctly."""
label = get_vat_rate_label("LU", Decimal("17.00"))
assert label == "Luxembourg VAT 17.00%"
def test_germany_label(self):
"""Germany label should be formatted correctly."""
label = get_vat_rate_label("DE", Decimal("19.00"))
assert label == "Germany VAT 19.00%"
class TestDetermineVatRegime:
"""Tests for determine_vat_regime function."""
def test_domestic_sale(self):
"""Same country should be domestic VAT."""
result = determine_vat_regime(
seller_country="LU",
buyer_country="LU",
)
assert result.regime == VATRegime.DOMESTIC
assert result.rate == Decimal("17.00")
assert result.destination_country is None
def test_domestic_sale_germany(self):
"""German domestic sale should use 19% VAT."""
result = determine_vat_regime(
seller_country="DE",
buyer_country="DE",
)
assert result.regime == VATRegime.DOMESTIC
assert result.rate == Decimal("19.00")
def test_cross_border_b2b_reverse_charge(self):
"""B2B with VAT number should be reverse charge (0%)."""
result = determine_vat_regime(
seller_country="LU",
buyer_country="DE",
buyer_vat_number="DE123456789",
)
assert result.regime == VATRegime.REVERSE_CHARGE
assert result.rate == Decimal("0.00")
assert result.destination_country == "DE"
assert result.label == "Reverse charge"
def test_cross_border_b2c_with_oss(self):
"""B2C with OSS should use destination country VAT."""
result = determine_vat_regime(
seller_country="LU",
buyer_country="DE",
seller_oss_registered=True,
)
assert result.regime == VATRegime.OSS
assert result.rate == Decimal("19.00") # German VAT
assert result.destination_country == "DE"
def test_cross_border_b2c_without_oss(self):
"""B2C without OSS should use origin country VAT."""
result = determine_vat_regime(
seller_country="LU",
buyer_country="DE",
seller_oss_registered=False,
)
assert result.regime == VATRegime.ORIGIN
assert result.rate == Decimal("17.00") # Luxembourg VAT
assert result.destination_country == "DE"
def test_non_eu_sale(self):
"""Non-EU sales should be VAT exempt."""
result = determine_vat_regime(
seller_country="LU",
buyer_country="US",
)
assert result.regime == VATRegime.EXEMPT
assert result.rate == Decimal("0.00")
assert result.destination_country == "US"
def test_default_to_luxembourg(self):
"""Empty seller country should default to Luxembourg."""
result = determine_vat_regime(
seller_country="",
buyer_country="LU",
)
assert result.regime == VATRegime.DOMESTIC
assert result.rate == Decimal("17.00")
class TestCalculateVatAmount:
"""Tests for calculate_vat_amount function."""
def test_standard_calculation(self):
"""Test standard VAT calculation."""
# €100 at 17% = €17
result = calculate_vat_amount(10000, Decimal("17.00"))
assert result == 1700
def test_zero_rate(self):
"""Zero rate should return 0."""
result = calculate_vat_amount(10000, Decimal("0.00"))
assert result == 0
def test_negative_rate(self):
"""Negative rate should return 0."""
result = calculate_vat_amount(10000, Decimal("-5.00"))
assert result == 0
def test_rounding(self):
"""Test proper rounding to cents."""
# €33.33 at 19% = €6.3327, should round to €6.33 (633 cents)
result = calculate_vat_amount(3333, Decimal("19.00"))
assert result == 633
def test_large_amount(self):
"""Test calculation with large amounts."""
# €10,000 at 27% = €2,700
result = calculate_vat_amount(1000000, Decimal("27.00"))
assert result == 270000