feat: add customer multiple addresses management

- Add CustomerAddressService with CRUD operations
- Add shop API endpoints for address management (GET, POST, PUT, DELETE)
- Add set default endpoint for address type
- Implement addresses.html with full UI (cards, modals, Alpine.js)
- Integrate saved addresses in checkout flow
  - Address selector dropdowns for shipping/billing
  - Auto-select default addresses
  - Save new address checkbox option
- Add country_iso field alongside country_name
- Add address exceptions (NotFound, LimitExceeded, InvalidType)
- Max 10 addresses per customer limit
- One default address per type (shipping/billing)
- Add unit tests for CustomerAddressService
- Add integration tests for shop addresses API

🤖 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 19:16:35 +01:00
parent ea0218746f
commit b5b32fb351
15 changed files with 3940 additions and 17 deletions

View File

@@ -0,0 +1,141 @@
"""Add country_iso to customer_addresses
Revision ID: r6f7a8b9c0d1
Revises: q5e6f7a8b9c0
Create Date: 2026-01-02
Adds country_iso field to customer_addresses table and renames
country to country_name for clarity.
This migration is idempotent - it checks for existing columns before
making changes.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "r6f7a8b9c0d1"
down_revision = "q5e6f7a8b9c0"
branch_labels = None
depends_on = None
# Country name to ISO code mapping for backfill
COUNTRY_ISO_MAP = {
"Luxembourg": "LU",
"Germany": "DE",
"France": "FR",
"Belgium": "BE",
"Netherlands": "NL",
"Austria": "AT",
"Italy": "IT",
"Spain": "ES",
"Portugal": "PT",
"Poland": "PL",
"Czech Republic": "CZ",
"Czechia": "CZ",
"Slovakia": "SK",
"Hungary": "HU",
"Romania": "RO",
"Bulgaria": "BG",
"Greece": "GR",
"Croatia": "HR",
"Slovenia": "SI",
"Estonia": "EE",
"Latvia": "LV",
"Lithuania": "LT",
"Finland": "FI",
"Sweden": "SE",
"Denmark": "DK",
"Ireland": "IE",
"Cyprus": "CY",
"Malta": "MT",
"United Kingdom": "GB",
"Switzerland": "CH",
"United States": "US",
}
def get_column_names(connection, table_name):
"""Get list of column names for a table."""
result = connection.execute(sa.text(f"PRAGMA table_info({table_name})"))
return [row[1] for row in result]
def upgrade() -> None:
connection = op.get_bind()
columns = get_column_names(connection, "customer_addresses")
# Check if we need to do anything (idempotent check)
has_country = "country" in columns
has_country_name = "country_name" in columns
has_country_iso = "country_iso" in columns
# If already has new columns, nothing to do
if has_country_name and has_country_iso:
print(" Columns country_name and country_iso already exist, skipping")
return
# If has old 'country' column, rename it and add country_iso
if has_country and not has_country_name:
with op.batch_alter_table("customer_addresses") as batch_op:
batch_op.alter_column(
"country",
new_column_name="country_name",
)
# Add country_iso if it doesn't exist
if not has_country_iso:
with op.batch_alter_table("customer_addresses") as batch_op:
batch_op.add_column(
sa.Column("country_iso", sa.String(5), nullable=True)
)
# Backfill country_iso from country_name
for country_name, iso_code in COUNTRY_ISO_MAP.items():
connection.execute(
sa.text(
"UPDATE customer_addresses SET country_iso = :iso "
"WHERE country_name = :name"
),
{"iso": iso_code, "name": country_name},
)
# Set default for any remaining NULL values
connection.execute(
sa.text(
"UPDATE customer_addresses SET country_iso = 'LU' "
"WHERE country_iso IS NULL"
)
)
# Make country_iso NOT NULL using batch operation
with op.batch_alter_table("customer_addresses") as batch_op:
batch_op.alter_column(
"country_iso",
existing_type=sa.String(5),
nullable=False,
)
def downgrade() -> None:
connection = op.get_bind()
columns = get_column_names(connection, "customer_addresses")
has_country_name = "country_name" in columns
has_country_iso = "country_iso" in columns
has_country = "country" in columns
# Only downgrade if in the new state
if has_country_name and not has_country:
with op.batch_alter_table("customer_addresses") as batch_op:
batch_op.alter_column(
"country_name",
new_column_name="country",
)
if has_country_iso:
with op.batch_alter_table("customer_addresses") as batch_op:
batch_op.drop_column("country_iso")

View File

@@ -21,7 +21,7 @@ Authentication:
from fastapi import APIRouter
# Import shop routers
from . import auth, carts, content_pages, messages, orders, products
from . import addresses, auth, carts, content_pages, messages, orders, products
# Create shop router
router = APIRouter()
@@ -30,6 +30,9 @@ router = APIRouter()
# SHOP API ROUTES (All vendor-context aware via middleware)
# ============================================================================
# Addresses (authenticated)
router.include_router(addresses.router, tags=["shop-addresses"])
# Authentication (public)
router.include_router(auth.router, tags=["shop-auth"])

View File

@@ -0,0 +1,269 @@
# app/api/v1/shop/addresses.py
"""
Shop Addresses API (Customer authenticated)
Endpoints for managing customer addresses in shop frontend.
Uses vendor from request.state (injected by VendorContextMiddleware).
Requires customer authentication.
"""
import logging
from fastapi import APIRouter, Depends, Path, Request
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.services.customer_address_service import customer_address_service
from models.database.customer import Customer
from models.schema.customer import (
CustomerAddressCreate,
CustomerAddressListResponse,
CustomerAddressResponse,
CustomerAddressUpdate,
)
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/addresses", response_model=CustomerAddressListResponse)
def list_addresses(
request: Request,
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
List all addresses for authenticated customer.
Vendor is automatically determined from request context.
Returns all addresses sorted by default first, then by creation date.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] list_addresses for customer {customer.id}",
extra={
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"customer_id": customer.id,
},
)
addresses = customer_address_service.list_addresses(
db=db, vendor_id=vendor.id, customer_id=customer.id
)
return CustomerAddressListResponse(
addresses=[CustomerAddressResponse.model_validate(a) for a in addresses],
total=len(addresses),
)
@router.get("/addresses/{address_id}", response_model=CustomerAddressResponse)
def get_address(
request: Request,
address_id: int = Path(..., description="Address ID", gt=0),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Get specific address by ID.
Vendor is automatically determined from request context.
Customer can only access their own addresses.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] get_address {address_id} for customer {customer.id}",
extra={
"vendor_id": vendor.id,
"customer_id": customer.id,
"address_id": address_id,
},
)
address = customer_address_service.get_address(
db=db, vendor_id=vendor.id, customer_id=customer.id, address_id=address_id
)
return CustomerAddressResponse.model_validate(address)
@router.post("/addresses", response_model=CustomerAddressResponse, status_code=201)
def create_address(
request: Request,
address_data: CustomerAddressCreate,
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Create new address for authenticated customer.
Vendor is automatically determined from request context.
Maximum 10 addresses per customer.
If is_default=True, clears default flag on other addresses of same type.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] create_address for customer {customer.id}",
extra={
"vendor_id": vendor.id,
"customer_id": customer.id,
"address_type": address_data.address_type,
},
)
address = customer_address_service.create_address(
db=db,
vendor_id=vendor.id,
customer_id=customer.id,
address_data=address_data,
)
db.commit()
logger.info(
f"Created address {address.id} for customer {customer.id} "
f"(type={address_data.address_type})",
extra={
"address_id": address.id,
"customer_id": customer.id,
"address_type": address_data.address_type,
},
)
return CustomerAddressResponse.model_validate(address)
@router.put("/addresses/{address_id}", response_model=CustomerAddressResponse)
def update_address(
request: Request,
address_data: CustomerAddressUpdate,
address_id: int = Path(..., description="Address ID", gt=0),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Update existing address.
Vendor is automatically determined from request context.
Customer can only update their own addresses.
If is_default=True, clears default flag on other addresses of same type.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] update_address {address_id} for customer {customer.id}",
extra={
"vendor_id": vendor.id,
"customer_id": customer.id,
"address_id": address_id,
},
)
address = customer_address_service.update_address(
db=db,
vendor_id=vendor.id,
customer_id=customer.id,
address_id=address_id,
address_data=address_data,
)
db.commit()
logger.info(
f"Updated address {address_id} for customer {customer.id}",
extra={"address_id": address_id, "customer_id": customer.id},
)
return CustomerAddressResponse.model_validate(address)
@router.delete("/addresses/{address_id}", status_code=204)
def delete_address(
request: Request,
address_id: int = Path(..., description="Address ID", gt=0),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Delete address.
Vendor is automatically determined from request context.
Customer can only delete their own addresses.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] delete_address {address_id} for customer {customer.id}",
extra={
"vendor_id": vendor.id,
"customer_id": customer.id,
"address_id": address_id,
},
)
customer_address_service.delete_address(
db=db, vendor_id=vendor.id, customer_id=customer.id, address_id=address_id
)
db.commit()
logger.info(
f"Deleted address {address_id} for customer {customer.id}",
extra={"address_id": address_id, "customer_id": customer.id},
)
@router.put("/addresses/{address_id}/default", response_model=CustomerAddressResponse)
def set_address_default(
request: Request,
address_id: int = Path(..., description="Address ID", gt=0),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Set address as default for its type.
Vendor is automatically determined from request context.
Clears default flag on other addresses of the same type.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[SHOP_API] set_address_default {address_id} for customer {customer.id}",
extra={
"vendor_id": vendor.id,
"customer_id": customer.id,
"address_id": address_id,
},
)
address = customer_address_service.set_default(
db=db, vendor_id=vendor.id, customer_id=customer.id, address_id=address_id
)
db.commit()
logger.info(
f"Set address {address_id} as default for customer {customer.id}",
extra={
"address_id": address_id,
"customer_id": customer.id,
"address_type": address.address_type,
},
)
return CustomerAddressResponse.model_validate(address)

View File

@@ -6,6 +6,13 @@ This module provides frontend-friendly exceptions with consistent error codes,
messages, and HTTP status mappings.
"""
# Address exceptions
from .address import (
AddressLimitExceededException,
AddressNotFoundException,
InvalidAddressTypeException,
)
# Admin exceptions
from .admin import (
AdminOperationException,

38
app/exceptions/address.py Normal file
View File

@@ -0,0 +1,38 @@
# app/exceptions/address.py
"""
Address-related custom exceptions.
Used for customer address management operations.
"""
from .base import BusinessLogicException, ResourceNotFoundException
class AddressNotFoundException(ResourceNotFoundException):
"""Raised when a customer address is not found."""
def __init__(self, address_id: str | int):
super().__init__(
resource_type="Address",
identifier=str(address_id),
)
class AddressLimitExceededException(BusinessLogicException):
"""Raised when customer exceeds maximum number of addresses."""
def __init__(self, max_addresses: int = 10):
super().__init__(
message=f"Maximum number of addresses ({max_addresses}) reached",
error_code="ADDRESS_LIMIT_EXCEEDED",
)
class InvalidAddressTypeException(BusinessLogicException):
"""Raised when an invalid address type is provided."""
def __init__(self, address_type: str):
super().__init__(
message=f"Invalid address type '{address_type}'. Must be 'shipping' or 'billing'",
error_code="INVALID_ADDRESS_TYPE",
)

View File

@@ -0,0 +1,314 @@
# app/services/customer_address_service.py
"""
Customer Address Service
Business logic for managing customer addresses with vendor isolation.
"""
import logging
from sqlalchemy.orm import Session
from app.exceptions import (
AddressLimitExceededException,
AddressNotFoundException,
)
from models.database.customer import CustomerAddress
from models.schema.customer import CustomerAddressCreate, CustomerAddressUpdate
logger = logging.getLogger(__name__)
class CustomerAddressService:
"""Service for managing customer addresses with vendor isolation."""
MAX_ADDRESSES_PER_CUSTOMER = 10
def list_addresses(
self, db: Session, vendor_id: int, customer_id: int
) -> list[CustomerAddress]:
"""
Get all addresses for a customer.
Args:
db: Database session
vendor_id: Vendor ID for isolation
customer_id: Customer ID
Returns:
List of customer addresses
"""
return (
db.query(CustomerAddress)
.filter(
CustomerAddress.vendor_id == vendor_id,
CustomerAddress.customer_id == customer_id,
)
.order_by(CustomerAddress.is_default.desc(), CustomerAddress.created_at.desc())
.all()
)
def get_address(
self, db: Session, vendor_id: int, customer_id: int, address_id: int
) -> CustomerAddress:
"""
Get a specific address with ownership validation.
Args:
db: Database session
vendor_id: Vendor ID for isolation
customer_id: Customer ID
address_id: Address ID
Returns:
Customer address
Raises:
AddressNotFoundException: If address not found or doesn't belong to customer
"""
address = (
db.query(CustomerAddress)
.filter(
CustomerAddress.id == address_id,
CustomerAddress.vendor_id == vendor_id,
CustomerAddress.customer_id == customer_id,
)
.first()
)
if not address:
raise AddressNotFoundException(address_id)
return address
def get_default_address(
self, db: Session, vendor_id: int, customer_id: int, address_type: str
) -> CustomerAddress | None:
"""
Get the default address for a specific type.
Args:
db: Database session
vendor_id: Vendor ID for isolation
customer_id: Customer ID
address_type: 'shipping' or 'billing'
Returns:
Default address or None if not set
"""
return (
db.query(CustomerAddress)
.filter(
CustomerAddress.vendor_id == vendor_id,
CustomerAddress.customer_id == customer_id,
CustomerAddress.address_type == address_type,
CustomerAddress.is_default == True, # noqa: E712
)
.first()
)
def create_address(
self,
db: Session,
vendor_id: int,
customer_id: int,
address_data: CustomerAddressCreate,
) -> CustomerAddress:
"""
Create a new address for a customer.
Args:
db: Database session
vendor_id: Vendor ID for isolation
customer_id: Customer ID
address_data: Address creation data
Returns:
Created customer address
Raises:
AddressLimitExceededException: If max addresses reached
"""
# Check address limit
current_count = (
db.query(CustomerAddress)
.filter(
CustomerAddress.vendor_id == vendor_id,
CustomerAddress.customer_id == customer_id,
)
.count()
)
if current_count >= self.MAX_ADDRESSES_PER_CUSTOMER:
raise AddressLimitExceededException(self.MAX_ADDRESSES_PER_CUSTOMER)
# If setting as default, clear other defaults of same type
if address_data.is_default:
self._clear_other_defaults(
db, vendor_id, customer_id, address_data.address_type
)
# Create the address
address = CustomerAddress(
vendor_id=vendor_id,
customer_id=customer_id,
address_type=address_data.address_type,
first_name=address_data.first_name,
last_name=address_data.last_name,
company=address_data.company,
address_line_1=address_data.address_line_1,
address_line_2=address_data.address_line_2,
city=address_data.city,
postal_code=address_data.postal_code,
country_name=address_data.country_name,
country_iso=address_data.country_iso,
is_default=address_data.is_default,
)
db.add(address)
db.flush()
logger.info(
f"Created address {address.id} for customer {customer_id} "
f"(type={address_data.address_type}, default={address_data.is_default})"
)
return address
def update_address(
self,
db: Session,
vendor_id: int,
customer_id: int,
address_id: int,
address_data: CustomerAddressUpdate,
) -> CustomerAddress:
"""
Update an existing address.
Args:
db: Database session
vendor_id: Vendor ID for isolation
customer_id: Customer ID
address_id: Address ID
address_data: Address update data
Returns:
Updated customer address
Raises:
AddressNotFoundException: If address not found
"""
address = self.get_address(db, vendor_id, customer_id, address_id)
# Update only provided fields
update_data = address_data.model_dump(exclude_unset=True)
# Handle default flag - clear others if setting to default
if update_data.get("is_default") is True:
# Use updated type if provided, otherwise current type
address_type = update_data.get("address_type", address.address_type)
self._clear_other_defaults(
db, vendor_id, customer_id, address_type, exclude_id=address_id
)
for field, value in update_data.items():
setattr(address, field, value)
db.flush()
logger.info(f"Updated address {address_id} for customer {customer_id}")
return address
def delete_address(
self, db: Session, vendor_id: int, customer_id: int, address_id: int
) -> None:
"""
Delete an address.
Args:
db: Database session
vendor_id: Vendor ID for isolation
customer_id: Customer ID
address_id: Address ID
Raises:
AddressNotFoundException: If address not found
"""
address = self.get_address(db, vendor_id, customer_id, address_id)
db.delete(address)
db.flush()
logger.info(f"Deleted address {address_id} for customer {customer_id}")
def set_default(
self, db: Session, vendor_id: int, customer_id: int, address_id: int
) -> CustomerAddress:
"""
Set an address as the default for its type.
Args:
db: Database session
vendor_id: Vendor ID for isolation
customer_id: Customer ID
address_id: Address ID
Returns:
Updated customer address
Raises:
AddressNotFoundException: If address not found
"""
address = self.get_address(db, vendor_id, customer_id, address_id)
# Clear other defaults of same type
self._clear_other_defaults(
db, vendor_id, customer_id, address.address_type, exclude_id=address_id
)
# Set this one as default
address.is_default = True
db.flush()
logger.info(
f"Set address {address_id} as default {address.address_type} "
f"for customer {customer_id}"
)
return address
def _clear_other_defaults(
self,
db: Session,
vendor_id: int,
customer_id: int,
address_type: str,
exclude_id: int | None = None,
) -> None:
"""
Clear the default flag on other addresses of the same type.
Args:
db: Database session
vendor_id: Vendor ID for isolation
customer_id: Customer ID
address_type: 'shipping' or 'billing'
exclude_id: Address ID to exclude from clearing
"""
query = db.query(CustomerAddress).filter(
CustomerAddress.vendor_id == vendor_id,
CustomerAddress.customer_id == customer_id,
CustomerAddress.address_type == address_type,
CustomerAddress.is_default == True, # noqa: E712
)
if exclude_id:
query = query.filter(CustomerAddress.id != exclude_id)
query.update({"is_default": False}, synchronize_session=False)
# Singleton instance
customer_address_service = CustomerAddressService()

View File

@@ -1,15 +1,562 @@
{# app/templates/shop/account/addresses.html #}
{% extends "shop/base.html" %}
{% block title %}My Addresses{% endblock %}
{% block title %}My Addresses - {{ vendor.name }}{% endblock %}
{% block alpine_data %}addressesPage(){% 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 Addresses</h1>
<!-- Page Header -->
<div class="flex justify-between items-center mb-8">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">My Addresses</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400">Manage your shipping and billing addresses</p>
</div>
<button @click="openAddModal()"
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"
style="background-color: var(--color-primary)">
<svg class="-ml-1 mr-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add Address
</button>
</div>
{# TODO: Implement address management #}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p class="text-gray-600 dark:text-gray-400">Address management coming soon...</p>
<!-- Loading State -->
<div x-show="loading" class="flex justify-center py-12">
<svg class="animate-spin h-8 w-8 text-primary" style="color: var(--color-primary)" 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 12h4z"></path>
</svg>
</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="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="ml-3 text-sm text-red-700 dark:text-red-400" x-text="error"></p>
</div>
</div>
<!-- Empty State -->
<div x-show="!loading && !error && addresses.length === 0"
class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-12 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" 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>
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-white">No addresses yet</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Add your first address to speed up checkout.</p>
<button @click="openAddModal()"
class="mt-6 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"
style="background-color: var(--color-primary)">
Add Your First Address
</button>
</div>
<!-- Addresses Grid -->
<div x-show="!loading && addresses.length > 0" class="grid grid-cols-1 md:grid-cols-2 gap-6">
<template x-for="address in addresses" :key="address.id">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6 relative">
<!-- Default Badge -->
<div x-show="address.is_default" class="absolute top-4 right-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="address.address_type === 'shipping' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' : 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'">
<svg class="-ml-0.5 mr-1 h-3 w-3" 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>
Default <span x-text="address.address_type === 'shipping' ? 'Shipping' : 'Billing'" class="ml-1"></span>
</span>
</div>
<!-- Address Type Badge (non-default) -->
<div x-show="!address.is_default" class="absolute top-4 right-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200"
x-text="address.address_type === 'shipping' ? 'Shipping' : 'Billing'"></span>
</div>
<!-- Address Content -->
<div class="pr-24">
<p class="text-lg font-medium text-gray-900 dark:text-white" x-text="address.first_name + ' ' + address.last_name"></p>
<p x-show="address.company" class="text-sm text-gray-600 dark:text-gray-400" x-text="address.company"></p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400" x-text="address.address_line_1"></p>
<p x-show="address.address_line_2" class="text-sm text-gray-600 dark:text-gray-400" x-text="address.address_line_2"></p>
<p class="text-sm text-gray-600 dark:text-gray-400" x-text="address.postal_code + ' ' + address.city"></p>
<p class="text-sm text-gray-600 dark:text-gray-400" x-text="address.country_name"></p>
</div>
<!-- Actions -->
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700 flex items-center space-x-4">
<button @click="openEditModal(address)"
class="text-sm font-medium text-primary hover:text-primary-dark"
style="color: var(--color-primary)">
Edit
</button>
<button x-show="!address.is_default"
@click="setAsDefault(address.id)"
class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
Set as Default
</button>
<button @click="openDeleteModal(address.id)"
class="text-sm font-medium text-red-600 hover:text-red-700">
Delete
</button>
</div>
</div>
</template>
</div>
</div>
<!-- Add/Edit Address Modal -->
<div x-show="showAddressModal"
x-cloak
class="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<!-- Overlay -->
<div x-show="showAddressModal"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="showAddressModal = false"
class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-75 transition-opacity"></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen">&#8203;</span>
<!-- Modal Panel -->
<div x-show="showAddressModal"
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"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div class="absolute top-0 right-0 pt-4 pr-4">
<button @click="showAddressModal = false" class="text-gray-400 hover:text-gray-500">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="sm:flex sm:items-start">
<div class="w-full">
<h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white mb-6"
x-text="editingAddress ? 'Edit Address' : 'Add New Address'"></h3>
<form @submit.prevent="saveAddress()" class="space-y-4">
<!-- Address Type -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address Type</label>
<select x-model="addressForm.address_type"
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-primary focus:ring-primary sm:text-sm">
<option value="shipping">Shipping Address</option>
<option value="billing">Billing Address</option>
</select>
</div>
<!-- Name Row -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">First Name *</label>
<input type="text" x-model="addressForm.first_name" required
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-primary focus:ring-primary sm:text-sm">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Last Name *</label>
<input type="text" x-model="addressForm.last_name" required
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-primary focus:ring-primary sm:text-sm">
</div>
</div>
<!-- Company -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Company (optional)</label>
<input type="text" x-model="addressForm.company"
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-primary focus:ring-primary sm:text-sm">
</div>
<!-- Address Line 1 -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address *</label>
<input type="text" x-model="addressForm.address_line_1" required
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-primary focus:ring-primary sm:text-sm">
</div>
<!-- Address Line 2 -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address Line 2 (optional)</label>
<input type="text" x-model="addressForm.address_line_2"
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-primary focus:ring-primary sm:text-sm">
</div>
<!-- City & Postal Code -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Postal Code *</label>
<input type="text" x-model="addressForm.postal_code" required
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-primary focus:ring-primary sm:text-sm">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">City *</label>
<input type="text" x-model="addressForm.city" required
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-primary focus:ring-primary sm:text-sm">
</div>
</div>
<!-- Country -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Country *</label>
<select x-model="addressForm.country_iso"
@change="addressForm.country_name = countries.find(c => c.iso === addressForm.country_iso)?.name || ''"
required
class="block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-primary focus:ring-primary sm:text-sm">
<template x-for="country in countries" :key="country.iso">
<option :value="country.iso" x-text="country.name"></option>
</template>
</select>
</div>
<!-- Default Checkbox -->
<div class="flex items-center">
<input type="checkbox" x-model="addressForm.is_default"
class="h-4 w-4 text-primary border-gray-300 rounded focus:ring-primary"
style="color: var(--color-primary)">
<label class="ml-2 text-sm text-gray-700 dark:text-gray-300">
Set as default <span x-text="addressForm.address_type === 'shipping' ? 'shipping' : 'billing'"></span> address
</label>
</div>
<!-- Error Message -->
<div x-show="formError" class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded p-3">
<p class="text-sm text-red-700 dark:text-red-400" x-text="formError"></p>
</div>
<!-- Actions -->
<div class="mt-6 flex justify-end space-x-3">
<button type="button" @click="showAddressModal = false"
class="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">
Cancel
</button>
<button type="submit"
:disabled="saving"
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary hover:bg-primary-dark disabled:opacity-50"
style="background-color: var(--color-primary)">
<span x-show="!saving" x-text="editingAddress ? 'Save Changes' : 'Add Address'"></span>
<span x-show="saving">Saving...</span>
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div x-show="showDeleteModal"
x-cloak
class="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<!-- Overlay -->
<div x-show="showDeleteModal"
x-transition:enter="ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="showDeleteModal = false"
class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-75 transition-opacity"></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen">&#8203;</span>
<!-- Modal Panel -->
<div x-show="showDeleteModal"
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"
x-transition:leave="ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
class="inline-block align-bottom bg-white dark:bg-gray-800 rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div class="sm:flex sm:items-start">
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 dark:bg-red-900 sm:mx-0 sm:h-10 sm:w-10">
<svg class="h-6 w-6 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white">Delete Address</h3>
<div class="mt-2">
<p class="text-sm text-gray-500 dark:text-gray-400">
Are you sure you want to delete this address? This action cannot be undone.
</p>
</div>
</div>
</div>
<div class="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button @click="confirmDelete()"
:disabled="deleting"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50">
<span x-show="!deleting">Delete</span>
<span x-show="deleting">Deleting...</span>
</button>
<button @click="showDeleteModal = false"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 shadow-sm px-4 py-2 bg-white dark:bg-gray-700 text-base font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600 sm:mt-0 sm:w-auto sm:text-sm">
Cancel
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function addressesPage() {
return {
...shopLayoutData(),
// State
loading: true,
error: '',
addresses: [],
// Modal state
showAddressModal: false,
showDeleteModal: false,
editingAddress: null,
deletingAddressId: null,
saving: false,
deleting: false,
formError: '',
// Form data
addressForm: {
address_type: 'shipping',
first_name: '',
last_name: '',
company: '',
address_line_1: '',
address_line_2: '',
city: '',
postal_code: '',
country_name: 'Luxembourg',
country_iso: 'LU',
is_default: false
},
// Countries list
countries: [
{ iso: 'LU', name: 'Luxembourg' },
{ iso: 'DE', name: 'Germany' },
{ iso: 'FR', name: 'France' },
{ iso: 'BE', name: 'Belgium' },
{ iso: 'NL', name: 'Netherlands' },
{ iso: 'AT', name: 'Austria' },
{ iso: 'IT', name: 'Italy' },
{ iso: 'ES', name: 'Spain' },
{ iso: 'PT', name: 'Portugal' },
{ iso: 'PL', name: 'Poland' },
{ iso: 'CZ', name: 'Czech Republic' },
{ iso: 'SK', name: 'Slovakia' },
{ iso: 'HU', name: 'Hungary' },
{ iso: 'RO', name: 'Romania' },
{ iso: 'BG', name: 'Bulgaria' },
{ iso: 'GR', name: 'Greece' },
{ iso: 'HR', name: 'Croatia' },
{ iso: 'SI', name: 'Slovenia' },
{ iso: 'EE', name: 'Estonia' },
{ iso: 'LV', name: 'Latvia' },
{ iso: 'LT', name: 'Lithuania' },
{ iso: 'FI', name: 'Finland' },
{ iso: 'SE', name: 'Sweden' },
{ iso: 'DK', name: 'Denmark' },
{ iso: 'IE', name: 'Ireland' },
{ iso: 'CY', name: 'Cyprus' },
{ iso: 'MT', name: 'Malta' },
{ iso: 'GB', name: 'United Kingdom' },
{ iso: 'CH', name: 'Switzerland' },
],
async init() {
await this.loadAddresses();
},
async loadAddresses() {
this.loading = true;
this.error = '';
try {
const token = localStorage.getItem('customer_token');
const response = await fetch('/api/v1/shop/addresses', {
headers: {
'Authorization': token ? `Bearer ${token}` : '',
}
});
if (!response.ok) {
if (response.status === 401) {
window.location.href = '{{ base_url }}shop/account/login';
return;
}
throw new Error('Failed to load addresses');
}
const data = await response.json();
this.addresses = data.addresses;
} catch (err) {
console.error('[ADDRESSES] Error loading:', err);
this.error = 'Failed to load addresses. Please try again.';
} finally {
this.loading = false;
}
},
openAddModal() {
this.editingAddress = null;
this.formError = '';
this.addressForm = {
address_type: 'shipping',
first_name: '',
last_name: '',
company: '',
address_line_1: '',
address_line_2: '',
city: '',
postal_code: '',
country_name: 'Luxembourg',
country_iso: 'LU',
is_default: false
};
this.showAddressModal = true;
},
openEditModal(address) {
this.editingAddress = address;
this.formError = '';
this.addressForm = {
address_type: address.address_type,
first_name: address.first_name,
last_name: address.last_name,
company: address.company || '',
address_line_1: address.address_line_1,
address_line_2: address.address_line_2 || '',
city: address.city,
postal_code: address.postal_code,
country_name: address.country_name,
country_iso: address.country_iso,
is_default: address.is_default
};
this.showAddressModal = true;
},
async saveAddress() {
this.saving = true;
this.formError = '';
try {
const token = localStorage.getItem('customer_token');
const url = this.editingAddress
? `/api/v1/shop/addresses/${this.editingAddress.id}`
: '/api/v1/shop/addresses';
const method = this.editingAddress ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '',
},
body: JSON.stringify(this.addressForm)
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.detail || data.message || 'Failed to save address');
}
this.showAddressModal = false;
this.showToast(this.editingAddress ? 'Address updated' : 'Address added', 'success');
await this.loadAddresses();
} catch (err) {
console.error('[ADDRESSES] Error saving:', err);
this.formError = err.message || 'Failed to save address. Please try again.';
} finally {
this.saving = false;
}
},
openDeleteModal(addressId) {
this.deletingAddressId = addressId;
this.showDeleteModal = true;
},
async confirmDelete() {
this.deleting = true;
try {
const token = localStorage.getItem('customer_token');
const response = await fetch(`/api/v1/shop/addresses/${this.deletingAddressId}`, {
method: 'DELETE',
headers: {
'Authorization': token ? `Bearer ${token}` : '',
}
});
if (!response.ok) {
throw new Error('Failed to delete address');
}
this.showDeleteModal = false;
this.showToast('Address deleted', 'success');
await this.loadAddresses();
} catch (err) {
console.error('[ADDRESSES] Error deleting:', err);
this.showToast('Failed to delete address', 'error');
} finally {
this.deleting = false;
}
},
async setAsDefault(addressId) {
try {
const token = localStorage.getItem('customer_token');
const response = await fetch(`/api/v1/shop/addresses/${addressId}/default`, {
method: 'PUT',
headers: {
'Authorization': token ? `Bearer ${token}` : '',
}
});
if (!response.ok) {
throw new Error('Failed to set default address');
}
this.showToast('Default address updated', 'success');
await this.loadAddresses();
} catch (err) {
console.error('[ADDRESSES] Error setting default:', err);
this.showToast('Failed to set default address', 'error');
}
}
}
}
</script>
{% endblock %}

View File

@@ -1,15 +1,926 @@
{# app/templates/shop/checkout.html #}
{% extends "shop/base.html" %}
{% block title %}Checkout{% endblock %}
{% block title %}Checkout - {{ vendor.name }}{% endblock %}
{% block alpine_data %}checkoutPage(){% 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">Checkout</h1>
{# TODO: Implement checkout process #}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<p class="text-gray-600 dark:text-gray-400">Checkout process coming soon...</p>
{# Breadcrumbs #}
<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/" class="hover:text-primary">Home</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>
<a href="{{ base_url }}shop/cart" class="hover:text-primary">Cart</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">Checkout</span>
</li>
</ol>
</nav>
{# Page Header #}
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-8">Checkout</h1>
{# 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>
{# Empty Cart #}
<div x-show="!loading && cartItems.length === 0" class="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
</svg>
<h3 class="text-xl font-semibold text-gray-700 dark:text-gray-200 mb-2">Your cart is empty</h3>
<p class="text-gray-500 dark:text-gray-400 mb-6">Add some products before checking out.</p>
<a href="{{ base_url }}shop/products" class="inline-block px-6 py-3 text-white rounded-lg transition-colors" style="background-color: var(--color-primary)">
Browse Products
</a>
</div>
{# Checkout Form #}
<div x-show="!loading && cartItems.length > 0" x-cloak>
<form @submit.prevent="placeOrder()" class="grid grid-cols-1 lg:grid-cols-3 gap-8">
{# Left Column - Forms #}
<div class="lg:col-span-2 space-y-6">
{# Step Indicator #}
<div class="flex items-center justify-center mb-8">
<div class="flex items-center">
<div class="flex items-center justify-center w-8 h-8 rounded-full text-white text-sm font-bold" style="background-color: var(--color-primary)">1</div>
<span class="ml-2 text-sm font-medium text-gray-900 dark:text-white">Information</span>
</div>
<div class="w-16 h-0.5 mx-4 bg-gray-300 dark:bg-gray-600"></div>
<div class="flex items-center">
<div class="flex items-center justify-center w-8 h-8 rounded-full text-white text-sm font-bold" :class="step >= 2 ? '' : 'bg-gray-300 dark:bg-gray-600'" :style="step >= 2 ? 'background-color: var(--color-primary)' : ''">2</div>
<span class="ml-2 text-sm font-medium" :class="step >= 2 ? 'text-gray-900 dark:text-white' : 'text-gray-400'">Shipping</span>
</div>
<div class="w-16 h-0.5 mx-4 bg-gray-300 dark:bg-gray-600"></div>
<div class="flex items-center">
<div class="flex items-center justify-center w-8 h-8 rounded-full text-white text-sm font-bold" :class="step >= 3 ? '' : 'bg-gray-300 dark:bg-gray-600'" :style="step >= 3 ? 'background-color: var(--color-primary)' : ''">3</div>
<span class="ml-2 text-sm font-medium" :class="step >= 3 ? 'text-gray-900 dark:text-white' : 'text-gray-400'">Review</span>
</div>
</div>
{# Error Message #}
<div x-show="error" class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<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>
{# Step 1: Contact & Shipping Address #}
<div x-show="step === 1" class="space-y-6">
{# Contact Information #}
<div class="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-4">Contact Information</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">First Name *</label>
<input type="text" x-model="customer.first_name" required
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Last Name *</label>
<input type="text" x-model="customer.last_name" required
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email *</label>
<input type="email" x-model="customer.email" required
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Phone</label>
<input type="tel" x-model="customer.phone"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
</div>
</div>
{# Shipping Address #}
<div class="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-4">Shipping Address</h2>
{# Saved Addresses Selector (only shown for logged in customers) #}
<div x-show="isLoggedIn && shippingAddresses.length > 0" class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Use a saved address</label>
<select x-model="selectedShippingAddressId" @change="populateFromSavedAddress('shipping')"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
<option value="">Enter a new address</option>
<template x-for="addr in shippingAddresses" :key="addr.id">
<option :value="addr.id" x-text="formatAddressOption(addr)"></option>
</template>
</select>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">First Name *</label>
<input type="text" x-model="shippingAddress.first_name" required
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Last Name *</label>
<input type="text" x-model="shippingAddress.last_name" required
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Company</label>
<input type="text" x-model="shippingAddress.company"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address *</label>
<input type="text" x-model="shippingAddress.address_line_1" required placeholder="Street and number"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address Line 2</label>
<input type="text" x-model="shippingAddress.address_line_2" placeholder="Apartment, suite, etc."
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Postal Code *</label>
<input type="text" x-model="shippingAddress.postal_code" required
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">City *</label>
<input type="text" x-model="shippingAddress.city" required
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Country *</label>
<select x-model="shippingAddress.country_iso" required
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
<option value="">Select a country</option>
<template x-for="country in countries" :key="country.code">
<option :value="country.code" x-text="country.name"></option>
</template>
</select>
</div>
{# Save Address Checkbox (only for new addresses when logged in) #}
<div x-show="isLoggedIn && !selectedShippingAddressId" class="md:col-span-2">
<label class="flex items-center cursor-pointer">
<input type="checkbox" x-model="saveShippingAddress" class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400">Save this address for future orders</span>
</label>
</div>
</div>
</div>
{# Billing Address #}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Billing Address</h2>
<label class="flex items-center cursor-pointer">
<input type="checkbox" x-model="sameAsShipping" class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400">Same as shipping</span>
</label>
</div>
{# Saved Addresses Selector (only shown for logged in customers when not same as shipping) #}
<div x-show="isLoggedIn && !sameAsShipping && billingAddresses.length > 0" class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Use a saved address</label>
<select x-model="selectedBillingAddressId" @change="populateFromSavedAddress('billing')"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
<option value="">Enter a new address</option>
<template x-for="addr in billingAddresses" :key="addr.id">
<option :value="addr.id" x-text="formatAddressOption(addr)"></option>
</template>
</select>
</div>
<div x-show="!sameAsShipping" x-collapse class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">First Name *</label>
<input type="text" x-model="billingAddress.first_name" :required="!sameAsShipping"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Last Name *</label>
<input type="text" x-model="billingAddress.last_name" :required="!sameAsShipping"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Company</label>
<input type="text" x-model="billingAddress.company"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address *</label>
<input type="text" x-model="billingAddress.address_line_1" :required="!sameAsShipping"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Address Line 2</label>
<input type="text" x-model="billingAddress.address_line_2"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Postal Code *</label>
<input type="text" x-model="billingAddress.postal_code" :required="!sameAsShipping"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">City *</label>
<input type="text" x-model="billingAddress.city" :required="!sameAsShipping"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Country *</label>
<select x-model="billingAddress.country_iso" :required="!sameAsShipping"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white">
<option value="">Select a country</option>
<template x-for="country in countries" :key="country.code">
<option :value="country.code" x-text="country.name"></option>
</template>
</select>
</div>
{# Save Address Checkbox (only for new addresses when logged in) #}
<div x-show="isLoggedIn && !selectedBillingAddressId" class="md:col-span-2">
<label class="flex items-center cursor-pointer">
<input type="checkbox" x-model="saveBillingAddress" class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400">Save this address for future orders</span>
</label>
</div>
</div>
</div>
<div class="flex justify-end">
<button type="button" @click="goToStep(2)"
class="px-6 py-3 text-white rounded-lg font-semibold transition-colors"
style="background-color: var(--color-primary)">
Continue to Shipping
</button>
</div>
</div>
{# Step 2: Shipping Method #}
<div x-show="step === 2" class="space-y-6">
<div class="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-4">Shipping Method</h2>
<div class="space-y-3">
<label class="flex items-center p-4 border-2 rounded-lg cursor-pointer transition-colors"
:class="shippingMethod === 'standard' ? 'border-primary bg-primary/5' : 'border-gray-200 dark:border-gray-600 hover:border-gray-300'"
:style="shippingMethod === 'standard' ? 'border-color: var(--color-primary)' : ''">
<input type="radio" name="shipping" value="standard" x-model="shippingMethod" class="hidden">
<div class="flex-1">
<p class="font-medium text-gray-900 dark:text-white">Standard Shipping</p>
<p class="text-sm text-gray-500 dark:text-gray-400">3-5 business days</p>
</div>
<span class="font-semibold text-gray-900 dark:text-white" x-text="subtotal >= 50 ? 'FREE' : '5.99'"></span>
</label>
<label class="flex items-center p-4 border-2 rounded-lg cursor-pointer transition-colors"
:class="shippingMethod === 'express' ? 'border-primary bg-primary/5' : 'border-gray-200 dark:border-gray-600 hover:border-gray-300'"
:style="shippingMethod === 'express' ? 'border-color: var(--color-primary)' : ''">
<input type="radio" name="shipping" value="express" x-model="shippingMethod" class="hidden">
<div class="flex-1">
<p class="font-medium text-gray-900 dark:text-white">Express Shipping</p>
<p class="text-sm text-gray-500 dark:text-gray-400">1-2 business days</p>
</div>
<span class="font-semibold text-gray-900 dark:text-white">9.99</span>
</label>
</div>
</div>
{# Order Notes #}
<div class="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-4">Order Notes (Optional)</h2>
<textarea x-model="customerNotes" rows="3" placeholder="Special instructions for your order..."
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent dark:bg-gray-700 dark:text-white"></textarea>
</div>
<div class="flex justify-between">
<button type="button" @click="goToStep(1)"
class="px-6 py-3 border border-gray-300 dark:border-gray-600 rounded-lg font-semibold hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
Back
</button>
<button type="button" @click="goToStep(3)"
class="px-6 py-3 text-white rounded-lg font-semibold transition-colors"
style="background-color: var(--color-primary)">
Review Order
</button>
</div>
</div>
{# Step 3: Review & Place Order #}
<div x-show="step === 3" class="space-y-6">
{# Review Contact Info #}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Contact Information</h2>
<button type="button" @click="goToStep(1)" class="text-sm text-primary hover:underline" style="color: var(--color-primary)">Edit</button>
</div>
<p class="text-gray-700 dark:text-gray-300" x-text="customer.first_name + ' ' + customer.last_name"></p>
<p class="text-gray-600 dark:text-gray-400" x-text="customer.email"></p>
<p x-show="customer.phone" class="text-gray-600 dark:text-gray-400" x-text="customer.phone"></p>
</div>
{# Review Addresses #}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Shipping Address</h2>
<button type="button" @click="goToStep(1)" class="text-sm text-primary hover:underline" style="color: var(--color-primary)">Edit</button>
</div>
<div class="text-gray-600 dark:text-gray-400 text-sm space-y-1">
<p class="font-medium text-gray-900 dark:text-white" x-text="shippingAddress.first_name + ' ' + shippingAddress.last_name"></p>
<p x-show="shippingAddress.company" x-text="shippingAddress.company"></p>
<p x-text="shippingAddress.address_line_1"></p>
<p x-show="shippingAddress.address_line_2" x-text="shippingAddress.address_line_2"></p>
<p x-text="shippingAddress.postal_code + ' ' + shippingAddress.city"></p>
<p x-text="getCountryName(shippingAddress.country_iso)"></p>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Billing Address</h2>
<button type="button" @click="goToStep(1)" class="text-sm text-primary hover:underline" style="color: var(--color-primary)">Edit</button>
</div>
<div class="text-gray-600 dark:text-gray-400 text-sm space-y-1">
<template x-if="sameAsShipping">
<p class="italic">Same as shipping address</p>
</template>
<template x-if="!sameAsShipping">
<div>
<p class="font-medium text-gray-900 dark:text-white" x-text="billingAddress.first_name + ' ' + billingAddress.last_name"></p>
<p x-show="billingAddress.company" x-text="billingAddress.company"></p>
<p x-text="billingAddress.address_line_1"></p>
<p x-show="billingAddress.address_line_2" x-text="billingAddress.address_line_2"></p>
<p x-text="billingAddress.postal_code + ' ' + billingAddress.city"></p>
<p x-text="getCountryName(billingAddress.country_iso)"></p>
</div>
</template>
</div>
</div>
</div>
{# Review Shipping #}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Shipping Method</h2>
<button type="button" @click="goToStep(2)" class="text-sm text-primary hover:underline" style="color: var(--color-primary)">Edit</button>
</div>
<p class="text-gray-700 dark:text-gray-300" x-text="shippingMethod === 'express' ? 'Express Shipping (1-2 business days)' : 'Standard Shipping (3-5 business days)'"></p>
</div>
{# Order Items Review #}
<div class="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-4">Order Items</h2>
<div class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="item in cartItems" :key="item.product_id">
<div class="py-4 flex items-center gap-4">
<img :src="item.image_url || '/static/shop/img/placeholder.svg'"
@error="$el.src = '/static/shop/img/placeholder.svg'"
class="w-16 h-16 object-cover rounded-lg">
<div class="flex-1 min-w-0">
<p class="font-medium text-gray-900 dark:text-white truncate" x-text="item.name"></p>
<p class="text-sm text-gray-500 dark:text-gray-400">Qty: <span x-text="item.quantity"></span></p>
</div>
<p class="font-semibold text-gray-900 dark:text-white" x-text="'€' + (parseFloat(item.price) * item.quantity).toFixed(2)"></p>
</div>
</template>
</div>
</div>
<div class="flex justify-between">
<button type="button" @click="goToStep(2)"
class="px-6 py-3 border border-gray-300 dark:border-gray-600 rounded-lg font-semibold hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
Back
</button>
<button type="submit" :disabled="submitting"
class="px-8 py-3 text-white rounded-lg font-semibold transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
style="background-color: var(--color-primary)">
<span x-show="!submitting">Place Order</span>
<span x-show="submitting" class="flex items-center">
<svg class="animate-spin -ml-1 mr-2 h-5 w-5 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>
Processing...
</span>
</button>
</div>
</div>
</div>
{# Right Column - Order Summary #}
<div class="lg:col-span-1">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow border border-gray-200 dark:border-gray-700 p-6 sticky top-4">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-6 pb-4 border-b border-gray-200 dark:border-gray-700">
Order Summary
</h3>
{# Cart Items Preview #}
<div class="space-y-3 mb-6 max-h-64 overflow-y-auto">
<template x-for="item in cartItems" :key="item.product_id">
<div class="flex items-center gap-3">
<div class="relative">
<img :src="item.image_url || '/static/shop/img/placeholder.svg'"
@error="$el.src = '/static/shop/img/placeholder.svg'"
class="w-12 h-12 object-cover rounded">
<span class="absolute -top-2 -right-2 w-5 h-5 bg-gray-500 text-white text-xs rounded-full flex items-center justify-center" x-text="item.quantity"></span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-white truncate" x-text="item.name"></p>
</div>
<p class="text-sm font-medium text-gray-900 dark:text-white" x-text="'€' + (parseFloat(item.price) * item.quantity).toFixed(2)"></p>
</div>
</template>
</div>
{# Totals #}
<div class="space-y-3 border-t border-gray-200 dark:border-gray-700 pt-4">
<div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Subtotal</span>
<span class="font-medium text-gray-900 dark:text-white" x-text="'€' + subtotal.toFixed(2)"></span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Shipping</span>
<span class="font-medium text-gray-900 dark:text-white" x-text="shippingCost === 0 ? 'FREE' : '€' + shippingCost.toFixed(2)"></span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-600 dark:text-gray-400">Tax (incl.)</span>
<span class="font-medium text-gray-900 dark:text-white" x-text="'€' + tax.toFixed(2)"></span>
</div>
<div class="flex justify-between text-lg font-bold pt-3 border-t border-gray-200 dark:border-gray-700">
<span class="text-gray-900 dark:text-white">Total</span>
<span style="color: var(--color-primary)" x-text="'€' + total.toFixed(2)"></span>
</div>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-4 text-center">
Free shipping on orders over €50
</p>
</div>
</div>
</form>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function checkoutPage() {
return {
...shopLayoutData(),
// State
loading: true,
submitting: false,
error: '',
step: 1,
// Cart data
cartItems: [],
// Customer info
customer: {
first_name: '',
last_name: '',
email: '',
phone: ''
},
// Saved addresses (for logged in customers)
isLoggedIn: false,
savedAddresses: [],
selectedShippingAddressId: '',
selectedBillingAddressId: '',
saveShippingAddress: false,
saveBillingAddress: false,
// Computed filtered addresses by type
get shippingAddresses() {
return this.savedAddresses.filter(a => a.address_type === 'shipping');
},
get billingAddresses() {
return this.savedAddresses.filter(a => a.address_type === 'billing');
},
// Shipping address
shippingAddress: {
first_name: '',
last_name: '',
company: '',
address_line_1: '',
address_line_2: '',
city: '',
postal_code: '',
country_iso: 'LU'
},
// Billing address
billingAddress: {
first_name: '',
last_name: '',
company: '',
address_line_1: '',
address_line_2: '',
city: '',
postal_code: '',
country_iso: 'LU'
},
sameAsShipping: true,
shippingMethod: 'standard',
customerNotes: '',
// Countries list
countries: [
{ code: 'LU', name: 'Luxembourg' },
{ code: 'DE', name: 'Germany' },
{ code: 'FR', name: 'France' },
{ code: 'BE', name: 'Belgium' },
{ code: 'NL', name: 'Netherlands' },
{ code: 'AT', name: 'Austria' },
{ code: 'IT', name: 'Italy' },
{ code: 'ES', name: 'Spain' },
{ code: 'PT', name: 'Portugal' },
{ code: 'PL', name: 'Poland' },
{ code: 'CZ', name: 'Czech Republic' },
{ code: 'SK', name: 'Slovakia' },
{ code: 'HU', name: 'Hungary' },
{ code: 'SI', name: 'Slovenia' },
{ code: 'HR', name: 'Croatia' },
{ code: 'RO', name: 'Romania' },
{ code: 'BG', name: 'Bulgaria' },
{ code: 'GR', name: 'Greece' },
{ code: 'IE', name: 'Ireland' },
{ code: 'DK', name: 'Denmark' },
{ code: 'SE', name: 'Sweden' },
{ code: 'FI', name: 'Finland' },
{ code: 'EE', name: 'Estonia' },
{ code: 'LV', name: 'Latvia' },
{ code: 'LT', name: 'Lithuania' },
{ code: 'MT', name: 'Malta' },
{ code: 'CY', name: 'Cyprus' }
],
// Computed
get subtotal() {
return this.cartItems.reduce((sum, item) => sum + (parseFloat(item.price) * item.quantity), 0);
},
get shippingCost() {
if (this.shippingMethod === 'express') return 9.99;
return this.subtotal >= 50 ? 0 : 5.99;
},
get tax() {
// VAT is included in price, calculate the VAT portion (17% for LU)
const vatRate = 0.17;
return this.subtotal * vatRate / (1 + vatRate);
},
get total() {
return this.subtotal + this.shippingCost;
},
async init() {
console.log('[CHECKOUT] Initializing...');
// Initialize session
if (typeof shopLayoutData === 'function') {
const baseData = shopLayoutData();
if (baseData.init) {
baseData.init.call(this);
}
}
// Check if customer is logged in and pre-fill data
await this.loadCustomerData();
// Load cart
await this.loadCart();
},
async loadCustomerData() {
try {
const response = await fetch('/api/v1/shop/auth/me');
if (response.ok) {
const data = await response.json();
this.isLoggedIn = true;
// Pre-fill customer info
this.customer.first_name = data.first_name || '';
this.customer.last_name = data.last_name || '';
this.customer.email = data.email || '';
this.customer.phone = data.phone || '';
// Pre-fill shipping address with customer name
this.shippingAddress.first_name = data.first_name || '';
this.shippingAddress.last_name = data.last_name || '';
console.log('[CHECKOUT] Customer data loaded');
// Load saved addresses for logged in customer
await this.loadSavedAddresses();
}
} catch (error) {
console.log('[CHECKOUT] No customer logged in or error:', error);
this.isLoggedIn = false;
}
},
async loadSavedAddresses() {
try {
const response = await fetch('/api/v1/shop/addresses');
if (response.ok) {
const data = await response.json();
this.savedAddresses = data.addresses || [];
console.log('[CHECKOUT] Saved addresses loaded:', this.savedAddresses.length);
// Auto-select default shipping address
const defaultShipping = this.shippingAddresses.find(a => a.is_default);
if (defaultShipping) {
this.selectedShippingAddressId = defaultShipping.id;
this.populateFromSavedAddress('shipping');
}
// Auto-select default billing address
const defaultBilling = this.billingAddresses.find(a => a.is_default);
if (defaultBilling) {
this.selectedBillingAddressId = defaultBilling.id;
this.populateFromSavedAddress('billing');
}
}
} catch (error) {
console.error('[CHECKOUT] Failed to load saved addresses:', error);
}
},
populateFromSavedAddress(type) {
const addressId = type === 'shipping' ? this.selectedShippingAddressId : this.selectedBillingAddressId;
const addresses = type === 'shipping' ? this.shippingAddresses : this.billingAddresses;
const targetAddress = type === 'shipping' ? this.shippingAddress : this.billingAddress;
if (!addressId) {
// Clear form when "Enter a new address" is selected
targetAddress.first_name = type === 'shipping' ? this.customer.first_name : '';
targetAddress.last_name = type === 'shipping' ? this.customer.last_name : '';
targetAddress.company = '';
targetAddress.address_line_1 = '';
targetAddress.address_line_2 = '';
targetAddress.city = '';
targetAddress.postal_code = '';
targetAddress.country_iso = 'LU';
return;
}
const savedAddr = addresses.find(a => a.id == addressId);
if (savedAddr) {
targetAddress.first_name = savedAddr.first_name || '';
targetAddress.last_name = savedAddr.last_name || '';
targetAddress.company = savedAddr.company || '';
targetAddress.address_line_1 = savedAddr.address_line_1 || '';
targetAddress.address_line_2 = savedAddr.address_line_2 || '';
targetAddress.city = savedAddr.city || '';
targetAddress.postal_code = savedAddr.postal_code || '';
targetAddress.country_iso = savedAddr.country_iso || 'LU';
console.log(`[CHECKOUT] Populated ${type} address from saved:`, savedAddr.id);
}
},
formatAddressOption(addr) {
const name = `${addr.first_name} ${addr.last_name}`.trim();
const location = `${addr.address_line_1}, ${addr.postal_code} ${addr.city}`;
const defaultBadge = addr.is_default ? ' (Default)' : '';
return `${name} - ${location}${defaultBadge}`;
},
async loadCart() {
this.loading = true;
try {
const response = await fetch(`/api/v1/shop/cart/${this.sessionId}`);
if (response.ok) {
const data = await response.json();
this.cartItems = data.items || [];
console.log('[CHECKOUT] Cart loaded:', this.cartItems.length, 'items');
}
} catch (error) {
console.error('[CHECKOUT] Failed to load cart:', error);
this.error = 'Failed to load cart';
} finally {
this.loading = false;
}
},
goToStep(newStep) {
// Validate current step before moving forward
if (newStep > this.step) {
if (this.step === 1 && !this.validateStep1()) {
return;
}
}
this.step = newStep;
window.scrollTo({ top: 0, behavior: 'smooth' });
},
validateStep1() {
// Validate customer info
if (!this.customer.first_name || !this.customer.last_name || !this.customer.email) {
this.error = 'Please fill in all required contact fields';
return false;
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(this.customer.email)) {
this.error = 'Please enter a valid email address';
return false;
}
// Validate shipping address
if (!this.shippingAddress.first_name || !this.shippingAddress.last_name ||
!this.shippingAddress.address_line_1 || !this.shippingAddress.city ||
!this.shippingAddress.postal_code || !this.shippingAddress.country_iso) {
this.error = 'Please fill in all required shipping address fields';
return false;
}
// Validate billing address if not same as shipping
if (!this.sameAsShipping) {
if (!this.billingAddress.first_name || !this.billingAddress.last_name ||
!this.billingAddress.address_line_1 || !this.billingAddress.city ||
!this.billingAddress.postal_code || !this.billingAddress.country_iso) {
this.error = 'Please fill in all required billing address fields';
return false;
}
}
this.error = '';
return true;
},
getCountryName(code) {
const country = this.countries.find(c => c.code === code);
return country ? country.name : code;
},
async saveNewAddresses() {
// Save shipping address if checkbox is checked and it's a new address
if (this.saveShippingAddress && !this.selectedShippingAddressId) {
try {
const country = this.countries.find(c => c.code === this.shippingAddress.country_iso);
const addressData = {
address_type: 'shipping',
first_name: this.shippingAddress.first_name,
last_name: this.shippingAddress.last_name,
company: this.shippingAddress.company || null,
address_line_1: this.shippingAddress.address_line_1,
address_line_2: this.shippingAddress.address_line_2 || null,
city: this.shippingAddress.city,
postal_code: this.shippingAddress.postal_code,
country_name: country ? country.name : this.shippingAddress.country_iso,
country_iso: this.shippingAddress.country_iso,
is_default: this.shippingAddresses.length === 0 // Make default if first address
};
const response = await fetch('/api/v1/shop/addresses', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(addressData)
});
if (response.ok) {
console.log('[CHECKOUT] Shipping address saved');
}
} catch (error) {
console.error('[CHECKOUT] Failed to save shipping address:', error);
}
}
// Save billing address if checkbox is checked, it's a new address, and not same as shipping
if (this.saveBillingAddress && !this.selectedBillingAddressId && !this.sameAsShipping) {
try {
const country = this.countries.find(c => c.code === this.billingAddress.country_iso);
const addressData = {
address_type: 'billing',
first_name: this.billingAddress.first_name,
last_name: this.billingAddress.last_name,
company: this.billingAddress.company || null,
address_line_1: this.billingAddress.address_line_1,
address_line_2: this.billingAddress.address_line_2 || null,
city: this.billingAddress.city,
postal_code: this.billingAddress.postal_code,
country_name: country ? country.name : this.billingAddress.country_iso,
country_iso: this.billingAddress.country_iso,
is_default: this.billingAddresses.length === 0 // Make default if first address
};
const response = await fetch('/api/v1/shop/addresses', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(addressData)
});
if (response.ok) {
console.log('[CHECKOUT] Billing address saved');
}
} catch (error) {
console.error('[CHECKOUT] Failed to save billing address:', error);
}
}
},
async placeOrder() {
this.error = '';
this.submitting = true;
try {
// Save new addresses if requested (only for logged in users with new addresses)
if (this.isLoggedIn) {
await this.saveNewAddresses();
}
// Build order data
const orderData = {
items: this.cartItems.map(item => ({
product_id: item.product_id,
quantity: item.quantity
})),
customer: {
first_name: this.customer.first_name,
last_name: this.customer.last_name,
email: this.customer.email,
phone: this.customer.phone || null
},
shipping_address: {
first_name: this.shippingAddress.first_name,
last_name: this.shippingAddress.last_name,
company: this.shippingAddress.company || null,
address_line_1: this.shippingAddress.address_line_1,
address_line_2: this.shippingAddress.address_line_2 || null,
city: this.shippingAddress.city,
postal_code: this.shippingAddress.postal_code,
country_iso: this.shippingAddress.country_iso
},
billing_address: this.sameAsShipping ? null : {
first_name: this.billingAddress.first_name,
last_name: this.billingAddress.last_name,
company: this.billingAddress.company || null,
address_line_1: this.billingAddress.address_line_1,
address_line_2: this.billingAddress.address_line_2 || null,
city: this.billingAddress.city,
postal_code: this.billingAddress.postal_code,
country_iso: this.billingAddress.country_iso
},
shipping_method: this.shippingMethod,
customer_notes: this.customerNotes || null,
session_id: this.sessionId
};
console.log('[CHECKOUT] Placing order:', orderData);
const response = await fetch('/api/v1/shop/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(orderData)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || 'Failed to place order');
}
const order = await response.json();
console.log('[CHECKOUT] Order placed:', order.order_number);
// Redirect to confirmation page
window.location.href = '{{ base_url }}shop/order-confirmation?order=' + order.order_number;
} catch (error) {
console.error('[CHECKOUT] Error placing order:', error);
this.error = error.message || 'Failed to place order. Please try again.';
} finally {
this.submitting = false;
}
}
};
}
</script>
{% endblock %}

View File

@@ -70,7 +70,8 @@ class CustomerAddress(Base, TimestampMixin):
address_line_2 = Column(String(255))
city = Column(String(100), nullable=False)
postal_code = Column(String(20), nullable=False)
country = Column(String(100), nullable=False)
country_name = Column(String(100), nullable=False)
country_iso = Column(String(5), nullable=False)
is_default = Column(Boolean, default=False)
# Relationships

View File

@@ -126,7 +126,8 @@ class CustomerAddressCreate(BaseModel):
address_line_2: str | None = Field(None, max_length=255)
city: str = Field(..., min_length=1, max_length=100)
postal_code: str = Field(..., min_length=1, max_length=20)
country: str = Field(..., min_length=2, max_length=100)
country_name: str = Field(..., min_length=2, max_length=100)
country_iso: str = Field(..., min_length=2, max_length=5)
is_default: bool = Field(default=False)
@@ -141,7 +142,8 @@ class CustomerAddressUpdate(BaseModel):
address_line_2: str | None = Field(None, max_length=255)
city: str | None = Field(None, min_length=1, max_length=100)
postal_code: str | None = Field(None, min_length=1, max_length=20)
country: str | None = Field(None, min_length=2, max_length=100)
country_name: str | None = Field(None, min_length=2, max_length=100)
country_iso: str | None = Field(None, min_length=2, max_length=5)
is_default: bool | None = None
@@ -159,7 +161,8 @@ class CustomerAddressResponse(BaseModel):
address_line_2: str | None
city: str
postal_code: str
country: str
country_name: str
country_iso: str
is_default: bool
created_at: datetime
updated_at: datetime
@@ -167,6 +170,13 @@ class CustomerAddressResponse(BaseModel):
model_config = {"from_attributes": True}
class CustomerAddressListResponse(BaseModel):
"""Schema for customer address list response."""
addresses: list[CustomerAddressResponse]
total: int
# ============================================================================
# Customer Preferences
# ============================================================================

View File

@@ -32,7 +32,7 @@ def test_customer(db, test_vendor):
@pytest.fixture
def test_customer_address(db, test_vendor, test_customer):
"""Create a test customer address."""
"""Create a test customer shipping address."""
address = CustomerAddress(
vendor_id=test_vendor.id,
customer_id=test_customer.id,
@@ -42,7 +42,8 @@ def test_customer_address(db, test_vendor, test_customer):
address_line_1="123 Main St",
city="Luxembourg",
postal_code="L-1234",
country="Luxembourg",
country_name="Luxembourg",
country_iso="LU",
is_default=True,
)
db.add(address)
@@ -51,6 +52,55 @@ def test_customer_address(db, test_vendor, test_customer):
return address
@pytest.fixture
def test_customer_billing_address(db, test_vendor, test_customer):
"""Create a test customer billing address."""
address = CustomerAddress(
vendor_id=test_vendor.id,
customer_id=test_customer.id,
address_type="billing",
first_name="John",
last_name="Doe",
company="Test Company S.A.",
address_line_1="456 Business Ave",
city="Luxembourg",
postal_code="L-5678",
country_name="Luxembourg",
country_iso="LU",
is_default=True,
)
db.add(address)
db.commit()
db.refresh(address)
return address
@pytest.fixture
def test_customer_multiple_addresses(db, test_vendor, test_customer):
"""Create multiple addresses for testing limits and listing."""
addresses = []
for i in range(3):
address = CustomerAddress(
vendor_id=test_vendor.id,
customer_id=test_customer.id,
address_type="shipping" if i % 2 == 0 else "billing",
first_name=f"Name{i}",
last_name="Test",
address_line_1=f"{i}00 Test Street",
city="Luxembourg",
postal_code=f"L-{1000+i}",
country_name="Luxembourg",
country_iso="LU",
is_default=(i == 0),
)
db.add(address)
addresses.append(address)
db.commit()
for addr in addresses:
db.refresh(addr)
return addresses
@pytest.fixture
def test_order(db, test_vendor, test_customer, test_customer_address):
"""Create a test order with customer/address snapshots."""

View File

@@ -0,0 +1 @@
# Shop API integration tests

View File

@@ -0,0 +1,621 @@
# tests/integration/api/v1/shop/test_addresses.py
"""Integration tests for shop addresses API endpoints.
Tests the /api/v1/shop/addresses/* endpoints.
All endpoints require customer JWT authentication with vendor context.
"""
from datetime import UTC, datetime, timedelta
from unittest.mock import patch
import pytest
from jose import jwt
from models.database.customer import Customer, CustomerAddress
@pytest.fixture
def shop_customer(db, test_vendor):
"""Create a test customer for shop API tests."""
from middleware.auth import AuthManager
auth_manager = AuthManager()
customer = Customer(
vendor_id=test_vendor.id,
email="shopcustomer@example.com",
hashed_password=auth_manager.hash_password("testpass123"),
first_name="Shop",
last_name="Customer",
customer_number="SHOP001",
is_active=True,
)
db.add(customer)
db.commit()
db.refresh(customer)
return customer
@pytest.fixture
def shop_customer_token(shop_customer, test_vendor):
"""Create JWT token for shop customer."""
from middleware.auth import AuthManager
auth_manager = AuthManager()
expires_delta = timedelta(minutes=auth_manager.token_expire_minutes)
expire = datetime.now(UTC) + expires_delta
payload = {
"sub": str(shop_customer.id),
"email": shop_customer.email,
"vendor_id": test_vendor.id,
"type": "customer",
"exp": expire,
"iat": datetime.now(UTC),
}
token = jwt.encode(
payload, auth_manager.secret_key, algorithm=auth_manager.algorithm
)
return token
@pytest.fixture
def shop_customer_headers(shop_customer_token):
"""Get authentication headers for shop customer."""
return {"Authorization": f"Bearer {shop_customer_token}"}
@pytest.fixture
def customer_address(db, test_vendor, shop_customer):
"""Create a test address for shop customer."""
address = CustomerAddress(
vendor_id=test_vendor.id,
customer_id=shop_customer.id,
address_type="shipping",
first_name="Ship",
last_name="Address",
address_line_1="123 Shipping St",
city="Luxembourg",
postal_code="L-1234",
country_name="Luxembourg",
country_iso="LU",
is_default=True,
)
db.add(address)
db.commit()
db.refresh(address)
return address
@pytest.fixture
def customer_billing_address(db, test_vendor, shop_customer):
"""Create a billing address for shop customer."""
address = CustomerAddress(
vendor_id=test_vendor.id,
customer_id=shop_customer.id,
address_type="billing",
first_name="Bill",
last_name="Address",
company="Test Company",
address_line_1="456 Billing Ave",
city="Esch-sur-Alzette",
postal_code="L-5678",
country_name="Luxembourg",
country_iso="LU",
is_default=True,
)
db.add(address)
db.commit()
db.refresh(address)
return address
@pytest.fixture
def other_customer(db, test_vendor):
"""Create another customer for testing access controls."""
from middleware.auth import AuthManager
auth_manager = AuthManager()
customer = Customer(
vendor_id=test_vendor.id,
email="othercustomer@example.com",
hashed_password=auth_manager.hash_password("otherpass123"),
first_name="Other",
last_name="Customer",
customer_number="OTHER001",
is_active=True,
)
db.add(customer)
db.commit()
db.refresh(customer)
return customer
@pytest.fixture
def other_customer_address(db, test_vendor, other_customer):
"""Create an address for another customer."""
address = CustomerAddress(
vendor_id=test_vendor.id,
customer_id=other_customer.id,
address_type="shipping",
first_name="Other",
last_name="Address",
address_line_1="999 Other St",
city="Differdange",
postal_code="L-9999",
country_name="Luxembourg",
country_iso="LU",
is_default=True,
)
db.add(address)
db.commit()
db.refresh(address)
return address
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.shop
class TestShopAddressesListAPI:
"""Test shop addresses list endpoint at /api/v1/shop/addresses."""
def test_list_addresses_requires_authentication(self, client, test_vendor):
"""Test that listing addresses requires authentication."""
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
response = client.get("/api/v1/shop/addresses")
assert response.status_code in [401, 403]
def test_list_addresses_success(
self,
client,
shop_customer_headers,
customer_address,
test_vendor,
shop_customer,
):
"""Test listing customer addresses successfully."""
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
with patch("app.api.deps._validate_customer_token") as mock_validate:
mock_validate.return_value = shop_customer
response = client.get(
"/api/v1/shop/addresses",
headers=shop_customer_headers,
)
assert response.status_code == 200
data = response.json()
assert "addresses" in data
assert "total" in data
assert data["total"] == 1
assert data["addresses"][0]["first_name"] == "Ship"
def test_list_addresses_empty(
self, client, shop_customer_headers, test_vendor, shop_customer
):
"""Test listing addresses when customer has none."""
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
with patch("app.api.deps._validate_customer_token") as mock_validate:
mock_validate.return_value = shop_customer
response = client.get(
"/api/v1/shop/addresses",
headers=shop_customer_headers,
)
assert response.status_code == 200
data = response.json()
assert data["total"] == 0
assert data["addresses"] == []
def test_list_addresses_multiple_types(
self,
client,
shop_customer_headers,
customer_address,
customer_billing_address,
test_vendor,
shop_customer,
):
"""Test listing addresses includes both shipping and billing."""
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
with patch("app.api.deps._validate_customer_token") as mock_validate:
mock_validate.return_value = shop_customer
response = client.get(
"/api/v1/shop/addresses",
headers=shop_customer_headers,
)
assert response.status_code == 200
data = response.json()
assert data["total"] == 2
types = {addr["address_type"] for addr in data["addresses"]}
assert "shipping" in types
assert "billing" in types
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.shop
class TestShopAddressDetailAPI:
"""Test shop address detail endpoint at /api/v1/shop/addresses/{address_id}."""
def test_get_address_success(
self,
client,
shop_customer_headers,
customer_address,
test_vendor,
shop_customer,
):
"""Test getting address details successfully."""
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
with patch("app.api.deps._validate_customer_token") as mock_validate:
mock_validate.return_value = shop_customer
response = client.get(
f"/api/v1/shop/addresses/{customer_address.id}",
headers=shop_customer_headers,
)
assert response.status_code == 200
data = response.json()
assert data["id"] == customer_address.id
assert data["first_name"] == "Ship"
assert data["country_iso"] == "LU"
assert data["country_name"] == "Luxembourg"
def test_get_address_not_found(
self, client, shop_customer_headers, test_vendor, shop_customer
):
"""Test getting non-existent address returns 404."""
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
with patch("app.api.deps._validate_customer_token") as mock_validate:
mock_validate.return_value = shop_customer
response = client.get(
"/api/v1/shop/addresses/99999",
headers=shop_customer_headers,
)
assert response.status_code == 404
def test_get_address_other_customer(
self,
client,
shop_customer_headers,
other_customer_address,
test_vendor,
shop_customer,
):
"""Test cannot access another customer's address."""
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
with patch("app.api.deps._validate_customer_token") as mock_validate:
mock_validate.return_value = shop_customer
response = client.get(
f"/api/v1/shop/addresses/{other_customer_address.id}",
headers=shop_customer_headers,
)
# Should return 404 to prevent enumeration
assert response.status_code == 404
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.shop
class TestShopAddressCreateAPI:
"""Test shop address creation at POST /api/v1/shop/addresses."""
def test_create_address_success(
self, client, shop_customer_headers, test_vendor, shop_customer
):
"""Test creating a new address."""
address_data = {
"address_type": "shipping",
"first_name": "New",
"last_name": "Address",
"address_line_1": "789 New St",
"city": "Luxembourg",
"postal_code": "L-1111",
"country_name": "Luxembourg",
"country_iso": "LU",
"is_default": False,
}
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
with patch("app.api.deps._validate_customer_token") as mock_validate:
mock_validate.return_value = shop_customer
response = client.post(
"/api/v1/shop/addresses",
headers=shop_customer_headers,
json=address_data,
)
assert response.status_code == 201
data = response.json()
assert data["first_name"] == "New"
assert data["last_name"] == "Address"
assert data["country_iso"] == "LU"
assert "id" in data
def test_create_address_with_company(
self, client, shop_customer_headers, test_vendor, shop_customer
):
"""Test creating address with company name."""
address_data = {
"address_type": "billing",
"first_name": "Business",
"last_name": "Address",
"company": "Acme Corp",
"address_line_1": "100 Business Park",
"city": "Luxembourg",
"postal_code": "L-2222",
"country_name": "Luxembourg",
"country_iso": "LU",
"is_default": True,
}
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
with patch("app.api.deps._validate_customer_token") as mock_validate:
mock_validate.return_value = shop_customer
response = client.post(
"/api/v1/shop/addresses",
headers=shop_customer_headers,
json=address_data,
)
assert response.status_code == 201
data = response.json()
assert data["company"] == "Acme Corp"
def test_create_address_validation_error(
self, client, shop_customer_headers, test_vendor, shop_customer
):
"""Test validation error for missing required fields."""
address_data = {
"address_type": "shipping",
"first_name": "Test",
# Missing required fields
}
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
with patch("app.api.deps._validate_customer_token") as mock_validate:
mock_validate.return_value = shop_customer
response = client.post(
"/api/v1/shop/addresses",
headers=shop_customer_headers,
json=address_data,
)
assert response.status_code == 422
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.shop
class TestShopAddressUpdateAPI:
"""Test shop address update at PUT /api/v1/shop/addresses/{address_id}."""
def test_update_address_success(
self,
client,
shop_customer_headers,
customer_address,
test_vendor,
shop_customer,
):
"""Test updating an address."""
update_data = {
"first_name": "Updated",
"city": "Esch-sur-Alzette",
}
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
with patch("app.api.deps._validate_customer_token") as mock_validate:
mock_validate.return_value = shop_customer
response = client.put(
f"/api/v1/shop/addresses/{customer_address.id}",
headers=shop_customer_headers,
json=update_data,
)
assert response.status_code == 200
data = response.json()
assert data["first_name"] == "Updated"
assert data["city"] == "Esch-sur-Alzette"
def test_update_address_not_found(
self, client, shop_customer_headers, test_vendor, shop_customer
):
"""Test updating non-existent address returns 404."""
update_data = {"first_name": "Test"}
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
with patch("app.api.deps._validate_customer_token") as mock_validate:
mock_validate.return_value = shop_customer
response = client.put(
"/api/v1/shop/addresses/99999",
headers=shop_customer_headers,
json=update_data,
)
assert response.status_code == 404
def test_update_address_other_customer(
self,
client,
shop_customer_headers,
other_customer_address,
test_vendor,
shop_customer,
):
"""Test cannot update another customer's address."""
update_data = {"first_name": "Hacked"}
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
with patch("app.api.deps._validate_customer_token") as mock_validate:
mock_validate.return_value = shop_customer
response = client.put(
f"/api/v1/shop/addresses/{other_customer_address.id}",
headers=shop_customer_headers,
json=update_data,
)
assert response.status_code == 404
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.shop
class TestShopAddressDeleteAPI:
"""Test shop address deletion at DELETE /api/v1/shop/addresses/{address_id}."""
def test_delete_address_success(
self,
client,
shop_customer_headers,
customer_address,
test_vendor,
shop_customer,
):
"""Test deleting an address."""
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
with patch("app.api.deps._validate_customer_token") as mock_validate:
mock_validate.return_value = shop_customer
response = client.delete(
f"/api/v1/shop/addresses/{customer_address.id}",
headers=shop_customer_headers,
)
assert response.status_code == 204
def test_delete_address_not_found(
self, client, shop_customer_headers, test_vendor, shop_customer
):
"""Test deleting non-existent address returns 404."""
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
with patch("app.api.deps._validate_customer_token") as mock_validate:
mock_validate.return_value = shop_customer
response = client.delete(
"/api/v1/shop/addresses/99999",
headers=shop_customer_headers,
)
assert response.status_code == 404
def test_delete_address_other_customer(
self,
client,
shop_customer_headers,
other_customer_address,
test_vendor,
shop_customer,
):
"""Test cannot delete another customer's address."""
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
with patch("app.api.deps._validate_customer_token") as mock_validate:
mock_validate.return_value = shop_customer
response = client.delete(
f"/api/v1/shop/addresses/{other_customer_address.id}",
headers=shop_customer_headers,
)
assert response.status_code == 404
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.shop
class TestShopAddressSetDefaultAPI:
"""Test set address as default at PUT /api/v1/shop/addresses/{address_id}/default."""
def test_set_default_success(
self,
client,
shop_customer_headers,
customer_address,
test_vendor,
shop_customer,
db,
):
"""Test setting address as default."""
# Create a second non-default address
second_address = CustomerAddress(
vendor_id=test_vendor.id,
customer_id=shop_customer.id,
address_type="shipping",
first_name="Second",
last_name="Address",
address_line_1="222 Second St",
city="Dudelange",
postal_code="L-3333",
country_name="Luxembourg",
country_iso="LU",
is_default=False,
)
db.add(second_address)
db.commit()
db.refresh(second_address)
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
with patch("app.api.deps._validate_customer_token") as mock_validate:
mock_validate.return_value = shop_customer
response = client.put(
f"/api/v1/shop/addresses/{second_address.id}/default",
headers=shop_customer_headers,
)
assert response.status_code == 200
data = response.json()
assert data["is_default"] is True
def test_set_default_not_found(
self, client, shop_customer_headers, test_vendor, shop_customer
):
"""Test setting default on non-existent address returns 404."""
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
with patch("app.api.deps._validate_customer_token") as mock_validate:
mock_validate.return_value = shop_customer
response = client.put(
"/api/v1/shop/addresses/99999/default",
headers=shop_customer_headers,
)
assert response.status_code == 404
def test_set_default_other_customer(
self,
client,
shop_customer_headers,
other_customer_address,
test_vendor,
shop_customer,
):
"""Test cannot set default on another customer's address."""
with patch("app.api.v1.shop.addresses.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
with patch("app.api.deps._validate_customer_token") as mock_validate:
mock_validate.return_value = shop_customer
response = client.put(
f"/api/v1/shop/addresses/{other_customer_address.id}/default",
headers=shop_customer_headers,
)
assert response.status_code == 404

View File

@@ -0,0 +1,557 @@
# tests/integration/api/v1/shop/test_orders.py
"""Integration tests for shop orders API endpoints.
Tests the /api/v1/shop/orders/* endpoints.
All endpoints require customer JWT authentication with vendor context.
"""
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from unittest.mock import patch, MagicMock
import pytest
from jose import jwt
from models.database.customer import Customer
from models.database.invoice import Invoice, InvoiceStatus, VendorInvoiceSettings
from models.database.order import Order, OrderItem
@pytest.fixture
def shop_customer(db, test_vendor):
"""Create a test customer for shop API tests."""
from middleware.auth import AuthManager
auth_manager = AuthManager()
customer = Customer(
vendor_id=test_vendor.id,
email="shopcustomer@example.com",
hashed_password=auth_manager.hash_password("testpass123"),
first_name="Shop",
last_name="Customer",
customer_number="SHOP001",
is_active=True,
)
db.add(customer)
db.commit()
db.refresh(customer)
return customer
@pytest.fixture
def shop_customer_token(shop_customer, test_vendor):
"""Create JWT token for shop customer."""
from middleware.auth import AuthManager
auth_manager = AuthManager()
expires_delta = timedelta(minutes=auth_manager.token_expire_minutes)
expire = datetime.now(UTC) + expires_delta
payload = {
"sub": str(shop_customer.id),
"email": shop_customer.email,
"vendor_id": test_vendor.id,
"type": "customer",
"exp": expire,
"iat": datetime.now(UTC),
}
token = jwt.encode(
payload, auth_manager.secret_key, algorithm=auth_manager.algorithm
)
return token
@pytest.fixture
def shop_customer_headers(shop_customer_token):
"""Get authentication headers for shop customer."""
return {"Authorization": f"Bearer {shop_customer_token}"}
@pytest.fixture
def shop_order(db, test_vendor, shop_customer):
"""Create a test order for shop customer."""
order = Order(
vendor_id=test_vendor.id,
customer_id=shop_customer.id,
order_number="SHOP-ORD-001",
status="pending",
channel="direct",
order_date=datetime.now(UTC),
subtotal_cents=10000,
tax_amount_cents=1700,
shipping_amount_cents=500,
total_amount_cents=12200,
currency="EUR",
customer_email=shop_customer.email,
customer_first_name=shop_customer.first_name,
customer_last_name=shop_customer.last_name,
ship_first_name=shop_customer.first_name,
ship_last_name=shop_customer.last_name,
ship_address_line_1="123 Shop St",
ship_city="Luxembourg",
ship_postal_code="L-1234",
ship_country_iso="LU",
bill_first_name=shop_customer.first_name,
bill_last_name=shop_customer.last_name,
bill_address_line_1="123 Shop St",
bill_city="Luxembourg",
bill_postal_code="L-1234",
bill_country_iso="LU",
# VAT fields
vat_regime="domestic",
vat_rate=Decimal("17.00"),
vat_rate_label="Luxembourg VAT 17.00%",
vat_destination_country=None,
)
db.add(order)
db.commit()
db.refresh(order)
return order
@pytest.fixture
def shop_order_processing(db, test_vendor, shop_customer):
"""Create a test order with processing status (eligible for invoice)."""
order = Order(
vendor_id=test_vendor.id,
customer_id=shop_customer.id,
order_number="SHOP-ORD-002",
status="processing",
channel="direct",
order_date=datetime.now(UTC),
subtotal_cents=20000,
tax_amount_cents=3400,
shipping_amount_cents=500,
total_amount_cents=23900,
currency="EUR",
customer_email=shop_customer.email,
customer_first_name=shop_customer.first_name,
customer_last_name=shop_customer.last_name,
ship_first_name=shop_customer.first_name,
ship_last_name=shop_customer.last_name,
ship_address_line_1="456 Shop Ave",
ship_city="Luxembourg",
ship_postal_code="L-5678",
ship_country_iso="LU",
bill_first_name=shop_customer.first_name,
bill_last_name=shop_customer.last_name,
bill_address_line_1="456 Shop Ave",
bill_city="Luxembourg",
bill_postal_code="L-5678",
bill_country_iso="LU",
# VAT fields
vat_regime="domestic",
vat_rate=Decimal("17.00"),
vat_rate_label="Luxembourg VAT 17.00%",
)
db.add(order)
db.flush()
# Add order item
item = OrderItem(
order_id=order.id,
product_id=1,
product_sku="TEST-SKU-001",
product_name="Test Product",
quantity=2,
unit_price_cents=10000,
total_price_cents=20000,
)
db.add(item)
db.commit()
db.refresh(order)
return order
@pytest.fixture
def shop_invoice_settings(db, test_vendor):
"""Create invoice settings for the vendor."""
settings = VendorInvoiceSettings(
vendor_id=test_vendor.id,
company_name="Shop Test Company S.A.",
company_address="123 Business St",
company_city="Luxembourg",
company_postal_code="L-1234",
company_country="LU",
vat_number="LU12345678",
invoice_prefix="INV",
invoice_next_number=1,
default_vat_rate=Decimal("17.00"),
)
db.add(settings)
db.commit()
db.refresh(settings)
return settings
@pytest.fixture
def shop_order_with_invoice(db, test_vendor, shop_customer, shop_invoice_settings):
"""Create an order with an existing invoice."""
order = Order(
vendor_id=test_vendor.id,
customer_id=shop_customer.id,
order_number="SHOP-ORD-003",
status="shipped",
channel="direct",
order_date=datetime.now(UTC),
subtotal_cents=15000,
tax_amount_cents=2550,
shipping_amount_cents=500,
total_amount_cents=18050,
currency="EUR",
customer_email=shop_customer.email,
customer_first_name=shop_customer.first_name,
customer_last_name=shop_customer.last_name,
ship_first_name=shop_customer.first_name,
ship_last_name=shop_customer.last_name,
ship_address_line_1="789 Shop Blvd",
ship_city="Luxembourg",
ship_postal_code="L-9999",
ship_country_iso="LU",
bill_first_name=shop_customer.first_name,
bill_last_name=shop_customer.last_name,
bill_address_line_1="789 Shop Blvd",
bill_city="Luxembourg",
bill_postal_code="L-9999",
bill_country_iso="LU",
vat_regime="domestic",
vat_rate=Decimal("17.00"),
)
db.add(order)
db.flush()
# Create invoice for this order
invoice = Invoice(
vendor_id=test_vendor.id,
order_id=order.id,
invoice_number="INV00001",
invoice_date=datetime.now(UTC),
status=InvoiceStatus.ISSUED.value,
seller_details={"company_name": "Shop Test Company S.A."},
buyer_details={"name": f"{shop_customer.first_name} {shop_customer.last_name}"},
line_items=[],
vat_rate=Decimal("17.00"),
subtotal_cents=15000,
vat_amount_cents=2550,
total_cents=18050,
)
db.add(invoice)
db.commit()
db.refresh(order)
db.refresh(invoice)
return order, invoice
@pytest.fixture
def other_customer(db, test_vendor):
"""Create another customer for testing access controls."""
from middleware.auth import AuthManager
auth_manager = AuthManager()
customer = Customer(
vendor_id=test_vendor.id,
email="othercustomer@example.com",
hashed_password=auth_manager.hash_password("otherpass123"),
first_name="Other",
last_name="Customer",
customer_number="OTHER001",
is_active=True,
)
db.add(customer)
db.commit()
db.refresh(customer)
return customer
@pytest.fixture
def other_customer_order(db, test_vendor, other_customer):
"""Create an order for another customer."""
order = Order(
vendor_id=test_vendor.id,
customer_id=other_customer.id,
order_number="OTHER-ORD-001",
status="processing",
channel="direct",
order_date=datetime.now(UTC),
subtotal_cents=5000,
tax_amount_cents=850,
total_amount_cents=5850,
currency="EUR",
customer_email=other_customer.email,
customer_first_name=other_customer.first_name,
customer_last_name=other_customer.last_name,
ship_first_name=other_customer.first_name,
ship_last_name=other_customer.last_name,
ship_address_line_1="Other St",
ship_city="Other City",
ship_postal_code="00000",
ship_country_iso="LU",
bill_first_name=other_customer.first_name,
bill_last_name=other_customer.last_name,
bill_address_line_1="Other St",
bill_city="Other City",
bill_postal_code="00000",
bill_country_iso="LU",
)
db.add(order)
db.commit()
db.refresh(order)
return order
# Note: Shop API endpoints require vendor context from VendorContextMiddleware.
# In integration tests, we mock the middleware to inject the vendor.
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.shop
class TestShopOrdersListAPI:
"""Test shop orders list endpoint at /api/v1/shop/orders."""
def test_list_orders_requires_authentication(self, client, test_vendor):
"""Test that listing orders requires authentication."""
with patch("app.api.v1.shop.orders.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
response = client.get("/api/v1/shop/orders")
# Without token, should get 401 or 403
assert response.status_code in [401, 403]
def test_list_orders_success(
self, client, shop_customer_headers, shop_order, test_vendor, shop_customer
):
"""Test listing customer orders successfully."""
# Mock vendor context and customer auth
with patch("app.api.v1.shop.orders.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
# Mock the dependency to return our customer
with patch("app.api.deps._validate_customer_token") as mock_validate:
mock_validate.return_value = shop_customer
response = client.get(
"/api/v1/shop/orders",
headers=shop_customer_headers,
)
assert response.status_code == 200
data = response.json()
assert "orders" in data
assert "total" in data
def test_list_orders_empty(
self, client, shop_customer_headers, test_vendor, shop_customer
):
"""Test listing orders when customer has none."""
with patch("app.api.v1.shop.orders.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
with patch("app.api.deps._validate_customer_token") as mock_validate:
mock_validate.return_value = shop_customer
response = client.get(
"/api/v1/shop/orders",
headers=shop_customer_headers,
)
assert response.status_code == 200
data = response.json()
assert data["total"] == 0
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.shop
class TestShopOrderDetailAPI:
"""Test shop order detail endpoint at /api/v1/shop/orders/{order_id}."""
def test_get_order_detail_success(
self, client, shop_customer_headers, shop_order, test_vendor, shop_customer
):
"""Test getting order details successfully."""
with patch("app.api.v1.shop.orders.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
with patch("app.api.deps._validate_customer_token") as mock_validate:
mock_validate.return_value = shop_customer
response = client.get(
f"/api/v1/shop/orders/{shop_order.id}",
headers=shop_customer_headers,
)
assert response.status_code == 200
data = response.json()
assert data["order_number"] == "SHOP-ORD-001"
assert data["status"] == "pending"
# Check VAT fields are present
assert "vat_regime" in data
assert "vat_rate" in data
def test_get_order_detail_not_found(
self, client, shop_customer_headers, test_vendor, shop_customer
):
"""Test getting non-existent order returns 404."""
with patch("app.api.v1.shop.orders.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
with patch("app.api.deps._validate_customer_token") as mock_validate:
mock_validate.return_value = shop_customer
response = client.get(
"/api/v1/shop/orders/99999",
headers=shop_customer_headers,
)
assert response.status_code == 404
def test_get_order_detail_other_customer(
self,
client,
shop_customer_headers,
other_customer_order,
test_vendor,
shop_customer,
):
"""Test cannot access another customer's order."""
with patch("app.api.v1.shop.orders.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
with patch("app.api.deps._validate_customer_token") as mock_validate:
mock_validate.return_value = shop_customer
response = client.get(
f"/api/v1/shop/orders/{other_customer_order.id}",
headers=shop_customer_headers,
)
# Should return 404 (not 403) to prevent enumeration
assert response.status_code == 404
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.shop
class TestShopOrderInvoiceDownloadAPI:
"""Test shop order invoice download at /api/v1/shop/orders/{order_id}/invoice."""
def test_download_invoice_pending_order_rejected(
self, client, shop_customer_headers, shop_order, test_vendor, shop_customer
):
"""Test cannot download invoice for pending orders."""
with patch("app.api.v1.shop.orders.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
with patch("app.api.deps._validate_customer_token") as mock_validate:
mock_validate.return_value = shop_customer
response = client.get(
f"/api/v1/shop/orders/{shop_order.id}/invoice",
headers=shop_customer_headers,
)
# Pending orders should not allow invoice download
assert response.status_code == 422
@pytest.mark.skip(reason="Requires PDF generation infrastructure")
def test_download_invoice_processing_order_creates_invoice(
self,
client,
shop_customer_headers,
shop_order_processing,
shop_invoice_settings,
test_vendor,
shop_customer,
):
"""Test downloading invoice for processing order creates it if needed."""
# This test requires actual PDF generation which may not be available
# in all environments. The logic is tested via:
# 1. test_download_invoice_pending_order_rejected - validates status check
# 2. Direct service tests for invoice creation
pass
@pytest.mark.skip(reason="Requires PDF generation infrastructure")
def test_download_invoice_existing_invoice(
self,
client,
shop_customer_headers,
shop_order_with_invoice,
test_vendor,
shop_customer,
):
"""Test downloading invoice when one already exists."""
# This test requires PDF file to exist on disk
# The service layer handles invoice retrieval properly
pass
def test_download_invoice_other_customer(
self,
client,
shop_customer_headers,
other_customer_order,
test_vendor,
shop_customer,
):
"""Test cannot download invoice for another customer's order."""
with patch("app.api.v1.shop.orders.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
with patch("app.api.deps._validate_customer_token") as mock_validate:
mock_validate.return_value = shop_customer
response = client.get(
f"/api/v1/shop/orders/{other_customer_order.id}/invoice",
headers=shop_customer_headers,
)
# Should return 404 to prevent enumeration
assert response.status_code == 404
def test_download_invoice_not_found(
self, client, shop_customer_headers, test_vendor, shop_customer
):
"""Test downloading invoice for non-existent order."""
with patch("app.api.v1.shop.orders.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
with patch("app.api.deps._validate_customer_token") as mock_validate:
mock_validate.return_value = shop_customer
response = client.get(
"/api/v1/shop/orders/99999/invoice",
headers=shop_customer_headers,
)
assert response.status_code == 404
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.shop
class TestShopOrderVATFields:
"""Test VAT fields in order responses."""
def test_order_includes_vat_fields(
self, client, shop_customer_headers, shop_order, test_vendor, shop_customer
):
"""Test order response includes VAT fields."""
with patch("app.api.v1.shop.orders.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
with patch("app.api.deps._validate_customer_token") as mock_validate:
mock_validate.return_value = shop_customer
response = client.get(
f"/api/v1/shop/orders/{shop_order.id}",
headers=shop_customer_headers,
)
assert response.status_code == 200
data = response.json()
# Verify VAT fields
assert data.get("vat_regime") == "domestic"
assert data.get("vat_rate") == 17.0
assert "Luxembourg VAT" in (data.get("vat_rate_label") or "")
def test_order_list_includes_vat_fields(
self, client, shop_customer_headers, shop_order, test_vendor, shop_customer
):
"""Test order list includes VAT fields."""
with patch("app.api.v1.shop.orders.getattr") as mock_getattr:
mock_getattr.return_value = test_vendor
with patch("app.api.deps._validate_customer_token") as mock_validate:
mock_validate.return_value = shop_customer
response = client.get(
"/api/v1/shop/orders",
headers=shop_customer_headers,
)
assert response.status_code == 200
data = response.json()
if data["orders"]:
order = data["orders"][0]
assert "vat_regime" in order
assert "vat_rate" in order

View File

@@ -0,0 +1,453 @@
# tests/unit/services/test_customer_address_service.py
"""
Unit tests for CustomerAddressService.
"""
import pytest
from app.exceptions import AddressLimitExceededException, AddressNotFoundException
from app.services.customer_address_service import CustomerAddressService
from models.database.customer import CustomerAddress
from models.schema.customer import CustomerAddressCreate, CustomerAddressUpdate
@pytest.fixture
def address_service():
"""Create CustomerAddressService instance."""
return CustomerAddressService()
@pytest.fixture
def multiple_addresses(db, test_vendor, test_customer):
"""Create multiple addresses for testing."""
addresses = []
for i in range(3):
address = CustomerAddress(
vendor_id=test_vendor.id,
customer_id=test_customer.id,
address_type="shipping" if i < 2 else "billing",
first_name=f"First{i}",
last_name=f"Last{i}",
address_line_1=f"{i+1} Test Street",
city="Luxembourg",
postal_code=f"L-{1000+i}",
country_name="Luxembourg",
country_iso="LU",
is_default=(i == 0), # First shipping is default
)
db.add(address)
addresses.append(address)
db.commit()
for a in addresses:
db.refresh(a)
return addresses
@pytest.mark.unit
class TestCustomerAddressServiceList:
"""Tests for list_addresses method."""
def test_list_addresses_empty(self, db, address_service, test_vendor, test_customer):
"""Test listing addresses when none exist."""
addresses = address_service.list_addresses(
db, vendor_id=test_vendor.id, customer_id=test_customer.id
)
assert addresses == []
def test_list_addresses_basic(
self, db, address_service, test_vendor, test_customer, test_customer_address
):
"""Test basic address listing."""
addresses = address_service.list_addresses(
db, vendor_id=test_vendor.id, customer_id=test_customer.id
)
assert len(addresses) == 1
assert addresses[0].id == test_customer_address.id
def test_list_addresses_ordered_by_default(
self, db, address_service, test_vendor, test_customer, multiple_addresses
):
"""Test addresses are ordered by default flag first."""
addresses = address_service.list_addresses(
db, vendor_id=test_vendor.id, customer_id=test_customer.id
)
# Default address should be first
assert addresses[0].is_default is True
def test_list_addresses_vendor_isolation(
self, db, address_service, test_vendor, test_customer, test_customer_address
):
"""Test addresses are isolated by vendor."""
# Query with different vendor ID
addresses = address_service.list_addresses(
db, vendor_id=99999, customer_id=test_customer.id
)
assert addresses == []
@pytest.mark.unit
class TestCustomerAddressServiceGet:
"""Tests for get_address method."""
def test_get_address_success(
self, db, address_service, test_vendor, test_customer, test_customer_address
):
"""Test getting address by ID."""
address = address_service.get_address(
db,
vendor_id=test_vendor.id,
customer_id=test_customer.id,
address_id=test_customer_address.id,
)
assert address.id == test_customer_address.id
assert address.first_name == test_customer_address.first_name
def test_get_address_not_found(
self, db, address_service, test_vendor, test_customer
):
"""Test error when address not found."""
with pytest.raises(AddressNotFoundException):
address_service.get_address(
db,
vendor_id=test_vendor.id,
customer_id=test_customer.id,
address_id=99999,
)
def test_get_address_wrong_customer(
self, db, address_service, test_vendor, test_customer, test_customer_address
):
"""Test cannot get another customer's address."""
with pytest.raises(AddressNotFoundException):
address_service.get_address(
db,
vendor_id=test_vendor.id,
customer_id=99999, # Different customer
address_id=test_customer_address.id,
)
@pytest.mark.unit
class TestCustomerAddressServiceGetDefault:
"""Tests for get_default_address method."""
def test_get_default_address_exists(
self, db, address_service, test_vendor, test_customer, multiple_addresses
):
"""Test getting default shipping address."""
address = address_service.get_default_address(
db,
vendor_id=test_vendor.id,
customer_id=test_customer.id,
address_type="shipping",
)
assert address is not None
assert address.is_default is True
assert address.address_type == "shipping"
def test_get_default_address_not_set(
self, db, address_service, test_vendor, test_customer, multiple_addresses
):
"""Test getting default billing when none is set."""
# Remove default from billing (none was set as default)
address = address_service.get_default_address(
db,
vendor_id=test_vendor.id,
customer_id=test_customer.id,
address_type="billing",
)
# The billing address exists but is not default
assert address is None
@pytest.mark.unit
class TestCustomerAddressServiceCreate:
"""Tests for create_address method."""
def test_create_address_success(
self, db, address_service, test_vendor, test_customer
):
"""Test creating a new address."""
address_data = CustomerAddressCreate(
address_type="shipping",
first_name="John",
last_name="Doe",
address_line_1="123 New Street",
city="Luxembourg",
postal_code="L-1234",
country_name="Luxembourg",
country_iso="LU",
is_default=False,
)
address = address_service.create_address(
db,
vendor_id=test_vendor.id,
customer_id=test_customer.id,
address_data=address_data,
)
db.commit()
assert address.id is not None
assert address.first_name == "John"
assert address.last_name == "Doe"
assert address.country_iso == "LU"
assert address.country_name == "Luxembourg"
def test_create_address_with_company(
self, db, address_service, test_vendor, test_customer
):
"""Test creating address with company name."""
address_data = CustomerAddressCreate(
address_type="billing",
first_name="Jane",
last_name="Doe",
company="Acme Corp",
address_line_1="456 Business Ave",
city="Luxembourg",
postal_code="L-5678",
country_name="Luxembourg",
country_iso="LU",
is_default=False,
)
address = address_service.create_address(
db,
vendor_id=test_vendor.id,
customer_id=test_customer.id,
address_data=address_data,
)
db.commit()
assert address.company == "Acme Corp"
def test_create_address_default_clears_others(
self, db, address_service, test_vendor, test_customer, multiple_addresses
):
"""Test creating default address clears other defaults of same type."""
# First address is default shipping
assert multiple_addresses[0].is_default is True
address_data = CustomerAddressCreate(
address_type="shipping",
first_name="New",
last_name="Default",
address_line_1="789 Main St",
city="Luxembourg",
postal_code="L-9999",
country_name="Luxembourg",
country_iso="LU",
is_default=True,
)
new_address = address_service.create_address(
db,
vendor_id=test_vendor.id,
customer_id=test_customer.id,
address_data=address_data,
)
db.commit()
# New address should be default
assert new_address.is_default is True
# Old default should be cleared
db.refresh(multiple_addresses[0])
assert multiple_addresses[0].is_default is False
def test_create_address_limit_exceeded(
self, db, address_service, test_vendor, test_customer
):
"""Test error when max addresses reached."""
# Create 10 addresses (max limit)
for i in range(10):
addr = CustomerAddress(
vendor_id=test_vendor.id,
customer_id=test_customer.id,
address_type="shipping",
first_name=f"Test{i}",
last_name="User",
address_line_1=f"{i} Street",
city="City",
postal_code="12345",
country_name="Luxembourg",
country_iso="LU",
)
db.add(addr)
db.commit()
# Try to create 11th address
address_data = CustomerAddressCreate(
address_type="shipping",
first_name="Eleventh",
last_name="User",
address_line_1="11 Street",
city="City",
postal_code="12345",
country_name="Luxembourg",
country_iso="LU",
is_default=False,
)
with pytest.raises(AddressLimitExceededException):
address_service.create_address(
db,
vendor_id=test_vendor.id,
customer_id=test_customer.id,
address_data=address_data,
)
@pytest.mark.unit
class TestCustomerAddressServiceUpdate:
"""Tests for update_address method."""
def test_update_address_success(
self, db, address_service, test_vendor, test_customer, test_customer_address
):
"""Test updating an address."""
update_data = CustomerAddressUpdate(
first_name="Updated",
city="New City",
)
address = address_service.update_address(
db,
vendor_id=test_vendor.id,
customer_id=test_customer.id,
address_id=test_customer_address.id,
address_data=update_data,
)
db.commit()
assert address.first_name == "Updated"
assert address.city == "New City"
# Unchanged fields should remain
assert address.last_name == test_customer_address.last_name
def test_update_address_set_default(
self, db, address_service, test_vendor, test_customer, multiple_addresses
):
"""Test setting address as default clears others."""
# Second address is not default
assert multiple_addresses[1].is_default is False
update_data = CustomerAddressUpdate(is_default=True)
address = address_service.update_address(
db,
vendor_id=test_vendor.id,
customer_id=test_customer.id,
address_id=multiple_addresses[1].id,
address_data=update_data,
)
db.commit()
assert address.is_default is True
# Old default should be cleared
db.refresh(multiple_addresses[0])
assert multiple_addresses[0].is_default is False
def test_update_address_not_found(
self, db, address_service, test_vendor, test_customer
):
"""Test error when address not found."""
update_data = CustomerAddressUpdate(first_name="Test")
with pytest.raises(AddressNotFoundException):
address_service.update_address(
db,
vendor_id=test_vendor.id,
customer_id=test_customer.id,
address_id=99999,
address_data=update_data,
)
@pytest.mark.unit
class TestCustomerAddressServiceDelete:
"""Tests for delete_address method."""
def test_delete_address_success(
self, db, address_service, test_vendor, test_customer, test_customer_address
):
"""Test deleting an address."""
address_id = test_customer_address.id
address_service.delete_address(
db,
vendor_id=test_vendor.id,
customer_id=test_customer.id,
address_id=address_id,
)
db.commit()
# Address should be gone
with pytest.raises(AddressNotFoundException):
address_service.get_address(
db,
vendor_id=test_vendor.id,
customer_id=test_customer.id,
address_id=address_id,
)
def test_delete_address_not_found(
self, db, address_service, test_vendor, test_customer
):
"""Test error when deleting non-existent address."""
with pytest.raises(AddressNotFoundException):
address_service.delete_address(
db,
vendor_id=test_vendor.id,
customer_id=test_customer.id,
address_id=99999,
)
@pytest.mark.unit
class TestCustomerAddressServiceSetDefault:
"""Tests for set_default method."""
def test_set_default_success(
self, db, address_service, test_vendor, test_customer, multiple_addresses
):
"""Test setting address as default."""
# Second shipping address is not default
assert multiple_addresses[1].is_default is False
assert multiple_addresses[1].address_type == "shipping"
address = address_service.set_default(
db,
vendor_id=test_vendor.id,
customer_id=test_customer.id,
address_id=multiple_addresses[1].id,
)
db.commit()
assert address.is_default is True
# Old default should be cleared
db.refresh(multiple_addresses[0])
assert multiple_addresses[0].is_default is False
def test_set_default_not_found(
self, db, address_service, test_vendor, test_customer
):
"""Test error when address not found."""
with pytest.raises(AddressNotFoundException):
address_service.set_default(
db,
vendor_id=test_vendor.id,
customer_id=test_customer.id,
address_id=99999,
)