Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
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>
148 lines
4.8 KiB
Python
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()
|