Files
mono/orders/services/checkout.py
giles fa431ee13e Split cart into 4 microservices: relations, likes, orders, page-config→blog
Phase 1 - Relations service (internal): owns ContainerRelation, exposes
get-children data + attach/detach-child actions. Retargeted events, blog,
market callers from cart to relations.

Phase 2 - Likes service (internal): unified Like model replaces ProductLike
and PostLike with generic target_type/target_slug/target_id. Exposes
is-liked, liked-slugs, liked-ids data + toggle action.

Phase 3 - PageConfig → blog: moved ownership to blog with direct DB queries,
removed proxy endpoints from cart.

Phase 4 - Orders service (public): owns Order/OrderItem + SumUp checkout
flow. Cart checkout now delegates to orders via create-order action.
Webhook/return routes and reconciliation moved to orders.

Phase 5 - Infrastructure: docker-compose, deploy.sh, Dockerfiles updated
for all 3 new services. Added orders_url helper and factory model imports.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 09:03:33 +00:00

148 lines
4.8 KiB
Python

"""Order creation and SumUp checkout helpers.
Moved from cart/bp/cart/services/checkout.py.
Only the order-side logic lives here; find_or_create_cart_item stays in cart.
"""
from __future__ import annotations
from typing import Optional
from urllib.parse import urlencode
from types import SimpleNamespace
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from shared.models.order import Order, OrderItem
from shared.config import config
from shared.events import emit_activity
from shared.infrastructure.actions import call_action
from shared.infrastructure.data_client import fetch_data
async def resolve_page_config_from_post_id(post_id: int) -> Optional[SimpleNamespace]:
"""Fetch the PageConfig for *post_id* from the blog service."""
raw_pc = await fetch_data(
"blog", "page-config",
params={"container_type": "page", "container_id": post_id},
required=False,
)
return SimpleNamespace(**raw_pc) if raw_pc else None
async def create_order(
session: AsyncSession,
cart_items: list[dict],
calendar_entries: list,
user_id: Optional[int],
session_id: Optional[str],
product_total: float,
calendar_total: float,
*,
ticket_total: float = 0,
page_post_id: int | None = None,
) -> Order:
"""Create an Order + OrderItems from serialized cart data."""
cart_total = product_total + calendar_total + ticket_total
currency = (cart_items[0].get("product_price_currency") if cart_items else None) or "GBP"
order = Order(
user_id=user_id,
session_id=session_id,
status="pending",
currency=currency,
total_amount=cart_total,
)
session.add(order)
await session.flush()
for ci in cart_items:
price = ci.get("product_special_price") or ci.get("product_regular_price") or 0
oi = OrderItem(
order=order,
product_id=ci["product_id"],
product_title=ci.get("product_title"),
product_slug=ci.get("product_slug"),
product_image=ci.get("product_image"),
quantity=ci.get("quantity", 1),
unit_price=price,
currency=currency,
)
session.add(oi)
await call_action("events", "claim-entries-for-order", payload={
"order_id": order.id, "user_id": user_id,
"session_id": session_id, "page_post_id": page_post_id,
})
await call_action("events", "claim-tickets-for-order", payload={
"order_id": order.id, "user_id": user_id,
"session_id": session_id, "page_post_id": page_post_id,
})
await emit_activity(
session,
activity_type="Create",
actor_uri="internal:orders",
object_type="rose:Order",
object_data={
"order_id": order.id,
"user_id": user_id,
"session_id": session_id,
},
source_type="order",
source_id=order.id,
)
return order
def build_sumup_description(cart_items: list[dict], order_id: int, *, ticket_count: int = 0) -> str:
titles = [ci.get("product_title") for ci in cart_items if ci.get("product_title")]
item_count = sum(ci.get("quantity", 1) for ci in cart_items)
parts = []
if titles:
if len(titles) <= 3:
parts.append(", ".join(titles))
else:
parts.append(", ".join(titles[:3]) + f" + {len(titles) - 3} more")
if ticket_count:
parts.append(f"{ticket_count} ticket{'s' if ticket_count != 1 else ''}")
summary = ", ".join(parts) if parts else "order items"
total_count = item_count + ticket_count
return f"Order {order_id} ({total_count} item{'s' if total_count != 1 else ''}): {summary}"
def build_sumup_reference(order_id: int, page_config=None) -> str:
if page_config and page_config.sumup_checkout_prefix:
prefix = page_config.sumup_checkout_prefix
else:
sumup_cfg = config().get("sumup", {}) or {}
prefix = sumup_cfg.get("checkout_reference_prefix", "")
return f"{prefix}{order_id}"
def build_webhook_url(base_url: str) -> str:
sumup_cfg = config().get("sumup", {}) or {}
webhook_secret = sumup_cfg.get("webhook_secret")
if webhook_secret:
sep = "&" if "?" in base_url else "?"
return f"{base_url}{sep}{urlencode({'token': webhook_secret})}"
return base_url
def validate_webhook_secret(token: Optional[str]) -> bool:
sumup_cfg = config().get("sumup", {}) or {}
webhook_secret = sumup_cfg.get("webhook_secret")
if not webhook_secret:
return True
return token is not None and token == webhook_secret
async def get_order_with_details(session: AsyncSession, order_id: int) -> Optional[Order]:
result = await session.execute(
select(Order)
.options(selectinload(Order.items))
.where(Order.id == order_id)
)
return result.scalar_one_or_none()