53 KiB
async placeOrder() {
this.placing = true;
try {
const order = await apiClient.post(
`/api/v1/public/vendors/${window.vendorId}/orders`,
{
shipping_address_id: this.selectedShippingAddress,
billing_address_id: this.selectedBillingAddress,
shipping_method: 'standard',
payment_method: this.paymentMethod,
customer_notes: ''
}
);
this.orderNumber = order.order_number;
this.currentStep = 4;
// Clear cart from localStorage
clearCart();
} catch (error) {
showNotification(error.message || 'Failed to place order', 'error');
} finally {
this.placing = false;
}
}
}
} </script> {% endblock %}
#### Customer Order History (`templates/shop/account/orders.html`)
```html
{% extends "shop/base_shop.html" %}
{% block content %}
<div x-data="orderHistory()" x-init="loadOrders()">
<div class="account-page">
<h1>My Orders</h1>
<template x-if="orders.length === 0 && !loading">
<div class="empty-state">
<p>You haven't placed any orders yet.</p>
<a href="/shop/products" class="btn btn-primary">Start Shopping</a>
</div>
</template>
<template x-if="orders.length > 0">
<div class="orders-list">
<template x-for="order in orders" :key="order.id">
<div class="order-card">
<div class="order-header">
<div class="order-info">
<h3 x-text="order.order_number"></h3>
<span class="order-date" x-text="formatDate(order.created_at)"></span>
</div>
<span
class="badge"
:class="{
'badge-warning': order.status === 'pending' || order.status === 'processing',
'badge-info': order.status === 'confirmed' || order.status === 'shipped',
'badge-success': order.status === 'delivered',
'badge-danger': order.status === 'cancelled'
}"
x-text="order.status"
></span>
</div>
<div class="order-items">
<template x-for="item in order.items" :key="item.id">
<div class="order-item">
<img :src="item.product_image" :alt="item.product_title">
<div class="item-details">
<p x-text="item.product_title"></p>
<p class="text-muted">Qty: <span x-text="item.quantity"></span></p>
</div>
<p class="item-price">€<span x-text="item.total_price.toFixed(2)"></span></p>
</div>
</template>
</div>
<div class="order-footer">
<p class="order-total">
Total: €<span x-text="order.total_amount.toFixed(2)"></span>
</p>
<a :href="`/shop/account/orders/${order.id}`" class="btn btn-sm">
View Details
</a>
</div>
</div>
</template>
</div>
</template>
<!-- Pagination -->
<div x-show="hasMore" class="text-center mt-3">
<button @click="loadMore()" class="btn btn-secondary" :disabled="loading">
<span x-show="!loading">Load More</span>
<span x-show="loading" class="loading-spinner"></span>
</button>
</div>
</div>
</div>
<script>
window.vendorId = {{ vendor.id }};
</script>
{% endblock %}
{% block extra_scripts %}
<script>
function orderHistory() {
return {
orders: [],
loading: false,
page: 0,
hasMore: true,
async loadOrders() {
this.loading = true;
try {
const newOrders = await apiClient.get(
`/api/v1/public/vendors/${window.vendorId}/orders/my-orders?skip=${this.page * 20}&limit=20`
);
this.orders = [...this.orders, ...newOrders];
this.hasMore = newOrders.length === 20;
} catch (error) {
showNotification('Failed to load orders', 'error');
} finally {
this.loading = false;
}
},
async loadMore() {
this.page++;
await this.loadOrders();
},
formatDate(dateString) {
return new Date(dateString).toLocaleDateString();
}
}
}
</script>
{% endblock %}
Vendor Order Management (templates/vendor/orders/list.html)
{% extends "vendor/base_vendor.html" %}
{% block title %}Order Management{% endblock %}
{% block content %}
<div x-data="orderManagement()" x-init="loadOrders()">
<!-- Page Header -->
<div class="page-header">
<h1>Orders</h1>
<div class="header-actions">
<button @click="exportOrders()" class="btn btn-secondary">
Export Orders
</button>
</div>
</div>
<!-- Filters -->
<div class="filters-bar">
<div class="filter-group">
<label>Status</label>
<select x-model="filters.status" @change="loadOrders()">
<option value="">All Statuses</option>
<option value="pending">Pending</option>
<option value="confirmed">Confirmed</option>
<option value="processing">Processing</option>
<option value="shipped">Shipped</option>
<option value="delivered">Delivered</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div class="filter-group">
<label>Search</label>
<input
type="text"
x-model="filters.search"
@input.debounce.500ms="loadOrders()"
placeholder="Order #, customer name..."
class="form-control"
>
</div>
<div class="filter-group">
<label>Date From</label>
<input
type="date"
x-model="filters.date_from"
@change="loadOrders()"
class="form-control"
>
</div>
<div class="filter-group">
<label>Date To</label>
<input
type="date"
x-model="filters.date_to"
@change="loadOrders()"
class="form-control"
>
</div>
</div>
<!-- Orders Table -->
<div class="card">
<template x-if="orders.length === 0 && !loading">
<p class="text-center text-muted">No orders found</p>
</template>
<template x-if="orders.length > 0">
<table class="data-table">
<thead>
<tr>
<th>Order #</th>
<th>Customer</th>
<th>Date</th>
<th>Items</th>
<th>Total</th>
<th>Status</th>
<th>Payment</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<template x-for="order in orders" :key="order.id">
<tr>
<td>
<strong x-text="order.order_number"></strong>
</td>
<td>
<div x-text="order.customer.first_name + ' ' + order.customer.last_name"></div>
<small class="text-muted" x-text="order.customer.email"></small>
</td>
<td x-text="formatDate(order.created_at)"></td>
<td x-text="order.items.length"></td>
<td>€<span x-text="order.total_amount.toFixed(2)"></span></td>
<td>
<span
class="badge"
:class="{
'badge-warning': order.status === 'pending',
'badge-info': order.status === 'processing' || order.status === 'confirmed',
'badge-primary': order.status === 'shipped',
'badge-success': order.status === 'delivered',
'badge-danger': order.status === 'cancelled'
}"
x-text="order.status"
></span>
</td>
<td>
<span
class="badge"
:class="{
'badge-warning': order.payment_status === 'pending',
'badge-success': order.payment_status === 'paid',
'badge-danger': order.payment_status === 'failed'
}"
x-text="order.payment_status"
></span>
</td>
<td>
<button
@click="viewOrder(order.id)"
class="btn btn-sm"
>
View
</button>
<button
@click="showStatusModal(order)"
class="btn btn-sm btn-secondary"
>
Update Status
</button>
</td>
</tr>
</template>
</tbody>
</table>
</template>
</div>
<!-- Status Update Modal -->
<div x-show="statusModal.show" class="modal-overlay" @click.self="statusModal.show = false">
<div class="modal">
<div class="modal-header">
<h3>Update Order Status</h3>
<button @click="statusModal.show = false" class="modal-close">×</button>
</div>
<div class="modal-body">
<form @submit.prevent="updateStatus()">
<div class="form-group">
<label>Order Number</label>
<input
type="text"
:value="statusModal.order?.order_number"
class="form-control"
readonly
>
</div>
<div class="form-group">
<label>Current Status</label>
<input
type="text"
:value="statusModal.order?.status"
class="form-control"
readonly
>
</div>
<div class="form-group">
<label>New Status</label>
<select x-model="statusModal.newStatus" class="form-control" required>
<option value="confirmed">Confirmed</option>
<option value="processing">Processing</option>
<option value="shipped">Shipped</option>
<option value="delivered">Delivered</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div class="form-group" x-show="statusModal.newStatus === 'shipped'">
<label>Tracking Number</label>
<input
type="text"
x-model="statusModal.trackingNumber"
class="form-control"
placeholder="Enter tracking number"
>
</div>
<div class="form-group">
<label>Notes (Optional)</label>
<textarea
x-model="statusModal.notes"
class="form-control"
rows="3"
></textarea>
</div>
<div class="modal-footer">
<button type="button" @click="statusModal.show = false" class="btn btn-secondary">
Cancel
</button>
<button type="submit" class="btn btn-primary" :disabled="statusModal.updating">
<span x-show="!statusModal.updating">Update Status</span>
<span x-show="statusModal.updating" class="loading-spinner"></span>
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function orderManagement() {
return {
orders: [],
loading: false,
filters: {
status: '',
search: '',
date_from: '',
date_to: ''
},
statusModal: {
show: false,
order: null,
newStatus: '',
trackingNumber: '',
notes: '',
updating: false
},
async loadOrders() {
this.loading = true;
try {
const params = new URLSearchParams();
if (this.filters.status) params.append('status', this.filters.status);
if (this.filters.search) params.append('search', this.filters.search);
if (this.filters.date_from) params.append('date_from', this.filters.date_from);
if (this.filters.date_to) params.append('date_to', this.filters.date_to);
this.orders = await apiClient.get(`/api/v1/vendor/orders?${params}`);
} catch (error) {
showNotification('Failed to load orders', 'error');
} finally {
this.loading = false;
}
},
viewOrder(orderId) {
window.location.href = `/vendor/orders/${orderId}`;
},
showStatusModal(order) {
this.statusModal.show = true;
this.statusModal.order = order;
this.statusModal.newStatus = order.status;
this.statusModal.trackingNumber = order.tracking_number || '';
this.statusModal.notes = '';
},
async updateStatus() {
this.statusModal.updating = true;
try {
await apiClient.put(
`/api/v1/vendor/orders/${this.statusModal.order.id}/status`,
{
status: this.statusModal.newStatus,
tracking_number: this.statusModal.trackingNumber,
notes: this.statusModal.notes
}
);
showNotification('Order status updated', 'success');
this.statusModal.show = false;
await this.loadOrders();
} catch (error) {
showNotification(error.message || 'Failed to update status', 'error');
} finally {
this.statusModal.updating = false;
}
},
async exportOrders() {
// Implement CSV export
showNotification('Export feature coming soon', 'info');
},
formatDate(dateString) {
return new Date(dateString).toLocaleDateString();
}
}
}
</script>
{% endblock %}
✅ Testing Checklist
Backend Tests
Order Creation
- Order created successfully from cart
- Order number generated uniquely
- Order items created with product snapshots
- Inventory reserved correctly
- Customer stats updated (total_orders, total_spent)
- Cart cleared after order placement
- Order status history recorded
- Shipping/billing addresses validated
- Tax calculated correctly
- Shipping cost calculated correctly
Order Management
- Customer can view their order history
- Customer can view order details
- Vendor can view all orders
- Order filtering works (status, date, search)
- Order search works (order number, customer name)
- Vendor can update order status
- Status history tracked correctly
- Tracking number can be added
Payment Integration
- Payment method validation
- Payment status tracking
- Stripe integration ready (placeholder)
Notifications
- Order confirmation email sent
- Status update email sent
- Email contains correct order details
Frontend Tests
Checkout Flow
- Multi-step checkout works
- Cart review displays correctly
- Address selection works
- New address creation works
- Payment method selection works
- Order summary calculates correctly
- Order placement succeeds
- Confirmation page displays
- Loading states work
- Error handling works
Customer Order History
- Order list displays correctly
- Order cards show correct information
- Status badges display correctly
- Order details link works
- Pagination works
- Empty state displays
Vendor Order Management
- Order table displays correctly
- Filters work (status, date, search)
- Order details modal works
- Status update modal works
- Status updates successfully
- Real-time status updates
- Export functionality (when implemented)
Integration Tests
Complete Order Flow
- Customer adds products to cart
- Customer proceeds to checkout
- Customer completes checkout
- Order appears in customer history
- Order appears in vendor orders
- Vendor updates order status
- Customer sees status update
- Emails sent at each step
Vendor Isolation
- Orders scoped to correct vendor
- Customers can only see their orders
- Vendors can only manage their orders
- Cross-vendor access prevented
Data Integrity
- Inventory correctly reserved/updated
- Product snapshots preserved
- Customer stats accurate
- Order totals calculate correctly
- Status history maintained
📝 Additional Features (Optional)
Payment Integration
- Stripe payment element integration
- Payment intent creation
- Payment confirmation handling
- Refund processing
Email Notifications
- Order confirmation template
- Order shipped notification
- Order delivered notification
- Order cancelled notification
Advanced Features
- Order notes/communication
- Partial refunds
- Order tracking page
- Invoice generation
- Bulk order status updates
- Order analytics dashboard
🚀 Deployment Checklist
Environment Setup
- Email service configured (SendGrid, Mailgun, etc.)
- Stripe API keys configured (test and live)
- Order number prefix configured
- Tax rates configured by country
- Shipping rates configured
Testing
- Complete checkout flow tested
- Payment processing tested (test mode)
- Email notifications tested
- Order management tested
- Mobile checkout tested
Production Readiness
- Stripe live mode enabled
- Email templates reviewed
- Error logging enabled
- Order monitoring set up
- Backup strategy for orders
🎯 Acceptance Criteria
Slice 5 is complete when:
-
Customer Checkout Works
- Multi-step checkout functional
- Address management works
- Orders can be placed successfully
- Confirmation displayed
-
Customer Order Management Works
- Order history displays
- Order details accessible
- Order tracking works
-
Vendor Order Management Works
- All orders visible
- Filtering and search work
- Status updates work
- Order details accessible
-
System Integration Complete
- Inventory updates correctly
- Emails sent automatically
- Data integrity maintained
- Vendor isolation enforced
-
Production Ready
- All tests pass
- Payment integration ready
- Email system configured
- Error handling robust
🎉 Platform Complete!
After completing Slice 5, your multi-tenant ecommerce platform is production-ready with:
✅ Multi-tenant foundation - Complete vendor isolation
✅ Admin management - Vendor creation and management
✅ Marketplace integration - CSV product imports
✅ Product catalog - Vendor product management
✅ Customer shopping - Browse, search, cart
✅ Order processing - Complete checkout and fulfillment
Next Steps for Production
-
Security Audit
- Review authentication
- Test vendor isolation
- Verify payment security
- Check data encryption
-
Performance Optimization
- Database indexing
- Query optimization
- Caching strategy
- CDN setup
-
Monitoring & Analytics
- Set up error tracking (Sentry)
- Configure analytics
- Set up uptime monitoring
- Create admin dashboards
-
Documentation
- User guides for vendors
- Admin documentation
- API documentation
- Deployment guide
-
Launch Preparation
- Beta testing with real vendors
- Load testing
- Backup procedures
- Support system
Slice 5 Status: 📋 Not Started
Dependencies: Slices 1, 2, 3, & 4 must be complete
Estimated Duration: 5 days
Platform Status After Completion: 🚀 Production Ready!# Slice 5: Order Processing
Complete Checkout and Order Management
Status: 📋 NOT STARTED
Timeline: Week 5 (5 days)
Prerequisites: Slices 1, 2, 3, & 4 complete
🎯 Slice Objectives
Complete the ecommerce transaction workflow with checkout, order placement, and order management for both customers and vendors.
User Stories
- As a Customer, I can proceed to checkout with my cart
- As a Customer, I can select shipping address
- As a Customer, I can place orders
- As a Customer, I can view my order history
- As a Customer, I can view order details and status
- As a Vendor Owner, I can view all customer orders
- As a Vendor Owner, I can manage orders (update status, view details)
- As a Vendor Owner, I can filter and search orders
- Order confirmation emails are sent automatically
Success Criteria
- Customers can complete multi-step checkout process
- Address selection/creation works
- Orders are created with proper vendor isolation
- Inventory is reserved/updated on order placement
- Customers can view their order history
- Customers can track order status
- Vendors can view all their orders
- Vendors can update order status
- Order status changes trigger notifications
- Order confirmation emails sent
- Payment integration ready (placeholder for Stripe)
📋 Backend Implementation
Database Models
Order Model (models/database/order.py)
class Order(Base, TimestampMixin):
"""Customer orders"""
__tablename__ = "orders"
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False)
# Order identification
order_number = Column(String, unique=True, nullable=False, index=True)
# Order status
status = Column(
String,
nullable=False,
default="pending"
) # pending, confirmed, processing, shipped, delivered, cancelled
# Amounts
subtotal = Column(Numeric(10, 2), nullable=False)
shipping_cost = Column(Numeric(10, 2), default=0)
tax_amount = Column(Numeric(10, 2), default=0)
discount_amount = Column(Numeric(10, 2), default=0)
total_amount = Column(Numeric(10, 2), nullable=False)
currency = Column(String(3), default="EUR")
# Addresses (snapshot at time of order)
shipping_address_id = Column(Integer, ForeignKey("customer_addresses.id"))
billing_address_id = Column(Integer, ForeignKey("customer_addresses.id"))
# Shipping
shipping_method = Column(String)
tracking_number = Column(String)
shipped_at = Column(DateTime, nullable=True)
delivered_at = Column(DateTime, nullable=True)
# Payment
payment_status = Column(String, default="pending") # pending, paid, failed, refunded
payment_method = Column(String) # stripe, paypal, etc.
payment_reference = Column(String) # External payment ID
paid_at = Column(DateTime, nullable=True)
# Customer notes
customer_notes = Column(Text)
internal_notes = Column(Text) # Vendor-only notes
# Metadata
ip_address = Column(String)
user_agent = Column(String)
# Relationships
vendor = relationship("Vendor", back_populates="orders")
customer = relationship("Customer", back_populates="orders")
items = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
shipping_address = relationship("CustomerAddress", foreign_keys=[shipping_address_id])
billing_address = relationship("CustomerAddress", foreign_keys=[billing_address_id])
status_history = relationship("OrderStatusHistory", back_populates="order")
# Indexes
__table_args__ = (
Index('ix_order_vendor_customer', 'vendor_id', 'customer_id'),
Index('ix_order_status', 'vendor_id', 'status'),
Index('ix_order_date', 'vendor_id', 'created_at'),
)
class OrderItem(Base, TimestampMixin):
"""Items in an order"""
__tablename__ = "order_items"
id = Column(Integer, primary_key=True, index=True)
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
# Product snapshot at time of order
product_sku = Column(String, nullable=False)
product_title = Column(String, nullable=False)
product_image = Column(String)
# Pricing
quantity = Column(Integer, nullable=False)
unit_price = Column(Numeric(10, 2), nullable=False)
total_price = Column(Numeric(10, 2), nullable=False)
# Tax
tax_rate = Column(Numeric(5, 2), default=0)
tax_amount = Column(Numeric(10, 2), default=0)
# Relationships
order = relationship("Order", back_populates="items")
product = relationship("Product", back_populates="order_items")
class OrderStatusHistory(Base, TimestampMixin):
"""Track order status changes"""
__tablename__ = "order_status_history"
id = Column(Integer, primary_key=True, index=True)
order_id = Column(Integer, ForeignKey("orders.id"), nullable=False)
# Status change
from_status = Column(String)
to_status = Column(String, nullable=False)
# Who made the change
changed_by_type = Column(String) # 'customer', 'vendor', 'system'
changed_by_id = Column(Integer)
# Notes
notes = Column(Text)
# Relationships
order = relationship("Order", back_populates="status_history")
Pydantic Schemas
Order Schemas (models/schema/order.py)
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
from decimal import Decimal
class OrderItemCreate(BaseModel):
"""Order item from cart"""
product_id: int
quantity: int
unit_price: Decimal
class OrderCreate(BaseModel):
"""Create new order"""
shipping_address_id: int
billing_address_id: int
shipping_method: Optional[str] = "standard"
payment_method: str = "stripe"
customer_notes: Optional[str] = None
class OrderItemResponse(BaseModel):
"""Order item details"""
id: int
product_id: int
product_sku: str
product_title: str
product_image: Optional[str]
quantity: int
unit_price: float
total_price: float
tax_amount: float
class Config:
from_attributes = True
class OrderResponse(BaseModel):
"""Order details"""
id: int
vendor_id: int
customer_id: int
order_number: str
status: str
subtotal: float
shipping_cost: float
tax_amount: float
discount_amount: float
total_amount: float
currency: str
payment_status: str
payment_method: Optional[str]
tracking_number: Optional[str]
customer_notes: Optional[str]
created_at: datetime
updated_at: datetime
items: List[OrderItemResponse]
class Config:
from_attributes = True
class OrderStatusUpdate(BaseModel):
"""Update order status"""
status: str = Field(..., regex="^(pending|confirmed|processing|shipped|delivered|cancelled)$")
tracking_number: Optional[str] = None
notes: Optional[str] = None
class OrderListFilters(BaseModel):
"""Filters for order list"""
status: Optional[str] = None
customer_id: Optional[int] = None
date_from: Optional[datetime] = None
date_to: Optional[datetime] = None
search: Optional[str] = None # Order number or customer name
Service Layer
Order Service (app/services/order_service.py)
from typing import List, Optional
from sqlalchemy.orm import Session
from datetime import datetime
from decimal import Decimal
class OrderService:
"""Handle order operations"""
async def create_order_from_cart(
self,
vendor_id: int,
customer_id: int,
cart_id: int,
order_data: OrderCreate,
db: Session
) -> Order:
"""Create order from shopping cart"""
# Get cart with items
cart = db.query(Cart).filter(
Cart.id == cart_id,
Cart.vendor_id == vendor_id
).first()
if not cart or not cart.items:
raise CartEmptyError("Cart is empty")
# Verify addresses belong to customer
shipping_addr = db.query(CustomerAddress).filter(
CustomerAddress.id == order_data.shipping_address_id,
CustomerAddress.customer_id == customer_id
).first()
billing_addr = db.query(CustomerAddress).filter(
CustomerAddress.id == order_data.billing_address_id,
CustomerAddress.customer_id == customer_id
).first()
if not shipping_addr or not billing_addr:
raise AddressNotFoundError()
# Calculate totals
subtotal = sum(item.line_total for item in cart.items)
shipping_cost = self._calculate_shipping(order_data.shipping_method)
tax_amount = self._calculate_tax(subtotal, shipping_addr.country)
total_amount = subtotal + shipping_cost + tax_amount
# Generate order number
order_number = self._generate_order_number(vendor_id, db)
# Create order
order = Order(
vendor_id=vendor_id,
customer_id=customer_id,
order_number=order_number,
status="pending",
subtotal=subtotal,
shipping_cost=shipping_cost,
tax_amount=tax_amount,
discount_amount=0,
total_amount=total_amount,
currency="EUR",
shipping_address_id=order_data.shipping_address_id,
billing_address_id=order_data.billing_address_id,
shipping_method=order_data.shipping_method,
payment_method=order_data.payment_method,
payment_status="pending",
customer_notes=order_data.customer_notes
)
db.add(order)
db.flush() # Get order ID
# Create order items from cart
for cart_item in cart.items:
# Verify product still available
product = db.query(Product).get(cart_item.product_id)
if not product or not product.is_active:
continue
# Check inventory
if product.track_inventory and product.stock_quantity < cart_item.quantity:
raise InsufficientInventoryError(f"Not enough stock for {product.title}")
# Create order item
order_item = OrderItem(
order_id=order.id,
product_id=cart_item.product_id,
product_sku=product.sku,
product_title=product.title,
product_image=product.featured_image,
quantity=cart_item.quantity,
unit_price=cart_item.unit_price,
total_price=cart_item.line_total,
tax_rate=self._get_tax_rate(shipping_addr.country),
tax_amount=cart_item.line_total * self._get_tax_rate(shipping_addr.country) / 100
)
db.add(order_item)
# Reserve inventory
if product.track_inventory:
await self._reserve_inventory(product, cart_item.quantity, order.id, db)
# Record initial status
status_history = OrderStatusHistory(
order_id=order.id,
from_status=None,
to_status="pending",
changed_by_type="customer",
changed_by_id=customer_id,
notes="Order created"
)
db.add(status_history)
# Clear cart
db.query(CartItem).filter(CartItem.cart_id == cart_id).delete()
# Update customer stats
customer = db.query(Customer).get(customer_id)
customer.total_orders += 1
customer.total_spent += float(total_amount)
db.commit()
db.refresh(order)
# Send order confirmation email
await self._send_order_confirmation(order, db)
return order
def get_customer_orders(
self,
vendor_id: int,
customer_id: int,
db: Session,
skip: int = 0,
limit: int = 20
) -> List[Order]:
"""Get customer's order history"""
return db.query(Order).filter(
Order.vendor_id == vendor_id,
Order.customer_id == customer_id
).order_by(
Order.created_at.desc()
).offset(skip).limit(limit).all()
def get_vendor_orders(
self,
vendor_id: int,
db: Session,
filters: OrderListFilters,
skip: int = 0,
limit: int = 50
) -> List[Order]:
"""Get vendor's orders with filters"""
query = db.query(Order).filter(Order.vendor_id == vendor_id)
if filters.status:
query = query.filter(Order.status == filters.status)
if filters.customer_id:
query = query.filter(Order.customer_id == filters.customer_id)
if filters.date_from:
query = query.filter(Order.created_at >= filters.date_from)
if filters.date_to:
query = query.filter(Order.created_at <= filters.date_to)
if filters.search:
query = query.filter(
or_(
Order.order_number.ilike(f"%{filters.search}%"),
Order.customer.has(
or_(
Customer.first_name.ilike(f"%{filters.search}%"),
Customer.last_name.ilike(f"%{filters.search}%"),
Customer.email.ilike(f"%{filters.search}%")
)
)
)
)
return query.order_by(
Order.created_at.desc()
).offset(skip).limit(limit).all()
async def update_order_status(
self,
vendor_id: int,
order_id: int,
status_update: OrderStatusUpdate,
changed_by_id: int,
db: Session
) -> Order:
"""Update order status"""
order = db.query(Order).filter(
Order.id == order_id,
Order.vendor_id == vendor_id
).first()
if not order:
raise OrderNotFoundError()
old_status = order.status
new_status = status_update.status
# Update order
order.status = new_status
if status_update.tracking_number:
order.tracking_number = status_update.tracking_number
if new_status == "shipped":
order.shipped_at = datetime.utcnow()
elif new_status == "delivered":
order.delivered_at = datetime.utcnow()
# Record status change
status_history = OrderStatusHistory(
order_id=order.id,
from_status=old_status,
to_status=new_status,
changed_by_type="vendor",
changed_by_id=changed_by_id,
notes=status_update.notes
)
db.add(status_history)
db.commit()
db.refresh(order)
# Send notification to customer
await self._send_status_update_email(order, old_status, new_status, db)
return order
def _generate_order_number(self, vendor_id: int, db: Session) -> str:
"""Generate unique order number"""
from models.database.vendor import Vendor
vendor = db.query(Vendor).get(vendor_id)
today = datetime.utcnow().strftime("%Y%m%d")
# Count orders today
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0)
count = db.query(Order).filter(
Order.vendor_id == vendor_id,
Order.created_at >= today_start
).count()
return f"{vendor.vendor_code}-{today}-{count+1:05d}"
def _calculate_shipping(self, method: str) -> Decimal:
"""Calculate shipping cost"""
shipping_rates = {
"standard": Decimal("5.00"),
"express": Decimal("15.00"),
"free": Decimal("0.00")
}
return shipping_rates.get(method, Decimal("5.00"))
def _calculate_tax(self, subtotal: Decimal, country: str) -> Decimal:
"""Calculate tax amount"""
tax_rate = self._get_tax_rate(country)
return subtotal * tax_rate / 100
def _get_tax_rate(self, country: str) -> Decimal:
"""Get tax rate by country"""
tax_rates = {
"LU": Decimal("17.00"), # Luxembourg VAT
"FR": Decimal("20.00"), # France VAT
"DE": Decimal("19.00"), # Germany VAT
"BE": Decimal("21.00"), # Belgium VAT
}
return tax_rates.get(country, Decimal("0.00"))
async def _reserve_inventory(
self,
product: Product,
quantity: int,
order_id: int,
db: Session
):
"""Reserve inventory for order"""
inventory = db.query(Inventory).filter(
Inventory.product_id == product.id
).first()
if inventory:
inventory.available_quantity -= quantity
inventory.reserved_quantity += quantity
# Record movement
movement = InventoryMovement(
inventory_id=inventory.id,
movement_type="reserved",
quantity_change=-quantity,
reference_type="order",
reference_id=order_id,
notes=f"Reserved for order #{order_id}"
)
db.add(movement)
# Update product stock count
product.stock_quantity -= quantity
async def _send_order_confirmation(self, order: Order, db: Session):
"""Send order confirmation email to customer"""
# Implement email sending
# This will be part of notification service
pass
async def _send_status_update_email(
self,
order: Order,
old_status: str,
new_status: str,
db: Session
):
"""Send order status update email"""
# Implement email sending
pass
API Endpoints
Customer Order Endpoints (app/api/v1/public/vendors/orders.py)
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List
router = APIRouter()
@router.post("", response_model=OrderResponse, status_code=201)
async def create_order(
vendor_id: int,
order_data: OrderCreate,
current_customer: Customer = Depends(get_current_customer),
db: Session = Depends(get_db)
):
"""Place order from cart"""
# Get customer's cart
cart = db.query(Cart).filter(
Cart.vendor_id == vendor_id,
Cart.customer_id == current_customer.id
).first()
if not cart:
raise HTTPException(status_code=404, detail="Cart not found")
service = OrderService()
order = await service.create_order_from_cart(
vendor_id,
current_customer.id,
cart.id,
order_data,
db
)
return order
@router.get("/my-orders", response_model=List[OrderResponse])
async def get_my_orders(
vendor_id: int,
skip: int = 0,
limit: int = 20,
current_customer: Customer = Depends(get_current_customer),
db: Session = Depends(get_db)
):
"""Get customer's order history"""
service = OrderService()
orders = service.get_customer_orders(
vendor_id,
current_customer.id,
db,
skip,
limit
)
return orders
@router.get("/my-orders/{order_id}", response_model=OrderResponse)
async def get_my_order(
vendor_id: int,
order_id: int,
current_customer: Customer = Depends(get_current_customer),
db: Session = Depends(get_db)
):
"""Get specific order details"""
order = db.query(Order).filter(
Order.id == order_id,
Order.vendor_id == vendor_id,
Order.customer_id == current_customer.id
).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
return order
Vendor Order Management Endpoints (app/api/v1/vendor/orders.py)
@router.get("", response_model=List[OrderResponse])
async def get_vendor_orders(
status: Optional[str] = None,
customer_id: Optional[int] = None,
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None,
search: Optional[str] = None,
skip: int = 0,
limit: int = 50,
current_user: User = Depends(get_current_vendor_user),
vendor: Vendor = Depends(get_current_vendor),
db: Session = Depends(get_db)
):
"""Get vendor's orders with filters"""
filters = OrderListFilters(
status=status,
customer_id=customer_id,
date_from=date_from,
date_to=date_to,
search=search
)
service = OrderService()
orders = service.get_vendor_orders(vendor.id, db, filters, skip, limit)
return orders
@router.get("/{order_id}", response_model=OrderResponse)
async def get_order_details(
order_id: int,
current_user: User = Depends(get_current_vendor_user),
vendor: Vendor = Depends(get_current_vendor),
db: Session = Depends(get_db)
):
"""Get order details"""
order = db.query(Order).filter(
Order.id == order_id,
Order.vendor_id == vendor.id
).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
return order
@router.put("/{order_id}/status", response_model=OrderResponse)
async def update_order_status(
order_id: int,
status_update: OrderStatusUpdate,
current_user: User = Depends(get_current_vendor_user),
vendor: Vendor = Depends(get_current_vendor),
db: Session = Depends(get_db)
):
"""Update order status"""
service = OrderService()
order = await service.update_order_status(
vendor.id,
order_id,
status_update,
current_user.id,
db
)
return order
🎨 Frontend Implementation
Templates
Checkout Page (templates/shop/checkout.html)
{% extends "shop/base_shop.html" %}
{% block content %}
<div x-data="checkout()" x-init="init()">
<!-- Progress Steps -->
<div class="checkout-steps">
<div class="step" :class="{'active': currentStep >= 1, 'completed': currentStep > 1}">
<span class="step-number">1</span>
<span class="step-label">Cart Review</span>
</div>
<div class="step" :class="{'active': currentStep >= 2, 'completed': currentStep > 2}">
<span class="step-number">2</span>
<span class="step-label">Shipping</span>
</div>
<div class="step" :class="{'active': currentStep >= 3, 'completed': currentStep > 3}">
<span class="step-number">3</span>
<span class="step-label">Payment</span>
</div>
<div class="step" :class="{'active': currentStep === 4}">
<span class="step-number">4</span>
<span class="step-label">Confirmation</span>
</div>
</div>
<!-- Step 1: Cart Review -->
<div x-show="currentStep === 1" class="checkout-section">
<h2>Review Your Cart</h2>
<div class="cart-items">
<template x-for="item in cart.items" :key="item.id">
<div class="cart-item">
<img :src="item.product_image" :alt="item.product_title">
<div class="item-details">
<h3 x-text="item.product_title"></h3>
<p>Quantity: <span x-text="item.quantity"></span></p>
<p class="price">€<span x-text="item.line_total.toFixed(2)"></span></p>
</div>
</div>
</template>
</div>
<div class="cart-summary">
<p>Subtotal: €<span x-text="cart.subtotal.toFixed(2)"></span></p>
<button @click="nextStep()" class="btn btn-primary btn-lg">
Continue to Shipping
</button>
</div>
</div>
<!-- Step 2: Shipping Address -->
<div x-show="currentStep === 2" class="checkout-section">
<h2>Shipping Address</h2>
<!-- Existing Addresses -->
<template x-if="addresses.length > 0">
<div class="address-selection">
<template x-for="address in addresses" :key="address.id">
<div
class="address-card"
:class="{'selected': selectedShippingAddress === address.id}"
@click="selectedShippingAddress = address.id"
>
<input
type="radio"
:value="address.id"
x-model="selectedShippingAddress"
>
<div class="address-info">
<strong x-text="`${address.first_name} ${address.last_name}`"></strong>
<p x-text="address.address_line1"></p>
<p x-text="`${address.postal_code} ${address.city}`"></p>
</div>
</div>
</template>
</div>
</template>
<button @click="showAddressForm = true" class="btn btn-secondary">
+ Add New Address
</button>
<!-- New Address Form -->
<div x-show="showAddressForm" class="address-form">
<!-- Address form fields -->
</div>
<div class="checkout-actions">
<button @click="prevStep()" class="btn btn-secondary">Back</button>
<button
@click="nextStep()"
class="btn btn-primary"
:disabled="!selectedShippingAddress"
>
Continue to Payment
</button>
</div>
</div>
<!-- Step 3: Payment -->
<div x-show="currentStep === 3" class="checkout-section">
<h2>Payment Method</h2>
<div class="payment-methods">
<div class="payment-option">
<input type="radio" value="stripe" x-model="paymentMethod">
<label>Credit Card (Stripe)</label>
</div>
</div>
<!-- Stripe Payment Element -->
<div id="payment-element">
<!-- Stripe.js will inject payment form here -->
</div>
<div class="order-summary">
<h3>Order Summary</h3>
<p>Subtotal: €<span x-text="cart.subtotal.toFixed(2)"></span></p>
<p>Shipping: €<span x-text="shippingCost.toFixed(2)"></span></p>
<p>Tax: €<span x-text="taxAmount.toFixed(2)"></span></p>
<p class="total">Total: €<span x-text="totalAmount.toFixed(2)"></span></p>
</div>
<div class="checkout-actions">
<button @click="prevStep()" class="btn btn-secondary">Back</button>
<button
@click="placeOrder()"
class="btn btn-primary btn-lg"
:disabled="placing"
>
<span x-show="!placing">Place Order</span>
<span x-show="placing" class="loading-spinner"></span>
</button>
</div>
</div>
<!-- Step 4: Confirmation -->
<div x-show="currentStep === 4" class="checkout-section">
<div class="order-confirmation">
<h2>✓ Order Confirmed!</h2>
<p>Thank you for your order!</p>
<p>Order Number: <strong x-text="orderNumber"></strong></p>
<p>We've sent a confirmation email to your address.</p>
<a href="/shop/account/orders" class="btn btn-primary">
View Order Details
</a>
</div>
</div>
</div>
<script>
window.vendorId = {{ vendor.id }};
window.customerId = {{ customer.id if customer else 'null' }};
</script>
{% endblock %}
{% block extra_scripts %}
<script src="https://js.stripe.com/v3/"></script>
<script>
function checkout() {
return {
currentStep: 1,
cart: {},
addresses: [],
selectedShippingAddress: null,
selectedBillingAddress: null,
paymentMethod: 'stripe',
showAddressForm: false,
placing: false,
orderNumber: null,
// Calculated
shippingCost: 5.00,
taxRate: 0.17,
get taxAmount() {
return this.cart.subtotal * this.taxRate;
},
get totalAmount() {
return this.cart.subtotal + this.shippingCost + this.taxAmount;
},
async init() {
await this.loadCart();
await this.loadAddresses();
},
async loadCart() {
try {
const sessionId = getSessionId();
this.cart = await apiClient.get(
`/api/v1/public/vendors/${window.vendorId}/cart/${sessionId}`
);
} catch (error) {
showNotification('Failed to load cart', 'error');
}
},
async loadAddresses() {
try {
this.addresses = await apiClient.get(
`/api/v1/public/vendors/${window.vendorId}/customers/addresses`
);
// Select default address
const defaultAddr = this.addresses.find(a => a.is_default);
if (defaultAddr) {
this.selectedShippingAddress = defaultAddr.id;
this.selectedBillingAddress = defaultAddr.id;
}
} catch (error) {
console.error('Failed to load addresses', error);
}
},
nextStep() {
if (this.currentStep < 4) {
this.currentStep++;
}
},
prevStep() {
if (this.currentStep > 1) {
this.currentStep--;
}
},
async placeOrder