Files
orion/docs/project-roadmap/slice5_doc.md

1628 lines
53 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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`)
```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:
1. **Customer Checkout Works**
- [ ] Multi-step checkout functional
- [ ] Address management works
- [ ] Orders can be placed successfully
- [ ] Confirmation displayed
2. **Customer Order Management Works**
- [ ] Order history displays
- [ ] Order details accessible
- [ ] Order tracking works
3. **Vendor Order Management Works**
- [ ] All orders visible
- [ ] Filtering and search work
- [ ] Status updates work
- [ ] Order details accessible
4. **System Integration Complete**
- [ ] Inventory updates correctly
- [ ] Emails sent automatically
- [ ] Data integrity maintained
- [ ] Vendor isolation enforced
5. **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
1. **Security Audit**
- Review authentication
- Test vendor isolation
- Verify payment security
- Check data encryption
2. **Performance Optimization**
- Database indexing
- Query optimization
- Caching strategy
- CDN setup
3. **Monitoring & Analytics**
- Set up error tracking (Sentry)
- Configure analytics
- Set up uptime monitoring
- Create admin dashboards
4. **Documentation**
- User guides for vendors
- Admin documentation
- API documentation
- Deployment guide
5. **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`)
```python
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`)
```python
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`)
```python
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`)
```python
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`)
```python
@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`)
```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