rswag-online/backend/app/services/order_service.py

260 lines
8.4 KiB
Python
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.

"""Order management service."""
import logging
from datetime import datetime
from uuid import UUID
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import get_settings
from app.models.order import Order, OrderItem, OrderStatus
from app.models.customer import Customer
from app.models.cart import Cart
from app.schemas.order import OrderResponse, OrderItemResponse
from app.services.flow_service import FlowService
logger = logging.getLogger(__name__)
settings = get_settings()
class OrderService:
"""Service for order operations."""
def __init__(self, db: AsyncSession):
self.db = db
async def get_order_by_id(self, order_id: UUID) -> OrderResponse | None:
"""Get order by ID."""
result = await self.db.execute(
select(Order)
.where(Order.id == order_id)
.options(selectinload(Order.items))
)
order = result.scalar_one_or_none()
if not order:
return None
return self._order_to_response(order)
async def get_order_by_id_and_email(
self,
order_id: UUID,
email: str,
) -> OrderResponse | None:
"""Get order by ID with email verification."""
result = await self.db.execute(
select(Order)
.where(Order.id == order_id, Order.shipping_email == email)
.options(selectinload(Order.items))
)
order = result.scalar_one_or_none()
if not order:
return None
return self._order_to_response(order)
async def list_orders(
self,
status: OrderStatus | None = None,
limit: int = 50,
offset: int = 0,
) -> list[OrderResponse]:
"""List orders with optional status filter."""
query = select(Order).options(selectinload(Order.items))
if status:
query = query.where(Order.status == status.value)
query = query.order_by(Order.created_at.desc()).limit(limit).offset(offset)
result = await self.db.execute(query)
orders = result.scalars().all()
return [self._order_to_response(o) for o in orders]
async def update_status(
self,
order_id: UUID,
status: OrderStatus,
) -> OrderResponse | None:
"""Update order status."""
result = await self.db.execute(
select(Order)
.where(Order.id == order_id)
.options(selectinload(Order.items))
)
order = result.scalar_one_or_none()
if not order:
return None
order.status = status.value
if status == OrderStatus.SHIPPED:
order.shipped_at = datetime.utcnow()
elif status == OrderStatus.DELIVERED:
order.delivered_at = datetime.utcnow()
await self.db.commit()
return self._order_to_response(order)
async def handle_successful_payment(self, payment: dict):
"""Handle successful Mollie payment.
Called by the Mollie webhook when payment status is 'paid'.
Mollie payment object contains metadata with cart_id.
"""
cart_id = payment.get("metadata", {}).get("cart_id")
if not cart_id:
return
# Get cart
result = await self.db.execute(
select(Cart)
.where(Cart.id == UUID(cart_id))
.options(selectinload(Cart.items))
)
cart = result.scalar_one_or_none()
if not cart or not cart.items:
return
# Extract amount from Mollie payment
amount = payment.get("amount", {})
total = float(amount.get("value", "0"))
currency = amount.get("currency", "USD")
# Create order
order = Order(
payment_provider="mollie",
payment_id=payment.get("id"),
payment_method=payment.get("method"),
status=OrderStatus.PAID.value,
shipping_email=payment.get("metadata", {}).get("email", ""),
subtotal=total,
total=total,
currency=currency,
paid_at=datetime.utcnow(),
)
self.db.add(order)
await self.db.flush()
# Create order items
for cart_item in cart.items:
order_item = OrderItem(
order_id=order.id,
product_slug=cart_item.product_slug,
product_name=cart_item.product_name,
variant=cart_item.variant,
quantity=cart_item.quantity,
unit_price=float(cart_item.unit_price),
pod_status="pending",
)
self.db.add(order_item)
await self.db.commit()
# Route revenue margin to TBFF flow → bonding curve
await self._deposit_revenue_to_flow(order)
# TODO: Submit to POD providers
# TODO: Send confirmation email
async def update_pod_status(
self,
pod_provider: str,
pod_order_id: str,
status: str,
tracking_number: str | None = None,
tracking_url: str | None = None,
):
"""Update POD status for order items."""
await self.db.execute(
update(OrderItem)
.where(
OrderItem.pod_provider == pod_provider,
OrderItem.pod_order_id == pod_order_id,
)
.values(
pod_status=status,
pod_tracking_number=tracking_number,
pod_tracking_url=tracking_url,
)
)
await self.db.commit()
async def _deposit_revenue_to_flow(self, order: Order):
"""Calculate margin and deposit to TBFF flow for bonding curve funding.
Revenue split:
total sale - POD cost estimate = margin
margin × flow_revenue_split = amount deposited to flow
flow → Transak on-ramp → USDC → bonding curve → $MYCO
"""
split = settings.flow_revenue_split
if split <= 0:
return
total = float(order.total) if order.total else 0
if total <= 0:
return
# Revenue split: configurable fraction of total goes to flow
# (POD costs + operational expenses kept as fiat remainder)
flow_amount = round(total * split, 2)
flow_service = FlowService()
await flow_service.deposit_revenue(
amount=flow_amount,
currency=order.currency or "USD",
order_id=str(order.id),
description=f"rSwag sale revenue split ({split:.0%} of ${total:.2f})",
)
async def _get_or_create_customer(self, email: str) -> Customer | None:
"""Get or create customer by email."""
if not email:
return None
result = await self.db.execute(
select(Customer).where(Customer.email == email)
)
customer = result.scalar_one_or_none()
if customer:
return customer
customer = Customer(email=email)
self.db.add(customer)
await self.db.flush()
return customer
def _order_to_response(self, order: Order) -> OrderResponse:
"""Convert Order model to response schema."""
items = [
OrderItemResponse(
id=item.id,
product_slug=item.product_slug,
product_name=item.product_name,
variant=item.variant,
quantity=item.quantity,
unit_price=float(item.unit_price),
pod_provider=item.pod_provider,
pod_status=item.pod_status,
pod_tracking_number=item.pod_tracking_number,
pod_tracking_url=item.pod_tracking_url,
)
for item in order.items
]
return OrderResponse(
id=order.id,
status=order.status,
shipping_name=order.shipping_name,
shipping_email=order.shipping_email,
shipping_city=order.shipping_city,
shipping_country=order.shipping_country,
subtotal=float(order.subtotal) if order.subtotal else None,
shipping_cost=float(order.shipping_cost) if order.shipping_cost else None,
tax=float(order.tax) if order.tax else None,
total=float(order.total) if order.total else None,
currency=order.currency,
items=items,
created_at=order.created_at,
paid_at=order.paid_at,
shipped_at=order.shipped_at,
)