Split cart into 4 microservices: relations, likes, orders, page-config→blog
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
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>
This commit is contained in:
6
orders/services/__init__.py
Normal file
6
orders/services/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Orders app service registration."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def register_domain_services() -> None:
|
||||
"""Register services for the orders app."""
|
||||
63
orders/services/check_sumup_status.py
Normal file
63
orders/services/check_sumup_status.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Check SumUp checkout status and update order accordingly.
|
||||
|
||||
Moved from cart/bp/cart/services/check_sumup_status.py.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from shared.browser.app.payments.sumup import get_checkout as sumup_get_checkout
|
||||
from shared.events import emit_activity
|
||||
from shared.infrastructure.actions import call_action
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
|
||||
|
||||
async def check_sumup_status(session, order, *, page_config=None):
|
||||
# Auto-fetch page_config from blog if order has one and caller didn't provide it
|
||||
if page_config is None and order.page_config_id:
|
||||
raw_pc = await fetch_data(
|
||||
"blog", "page-config-by-id",
|
||||
params={"id": order.page_config_id},
|
||||
required=False,
|
||||
)
|
||||
if raw_pc:
|
||||
page_config = SimpleNamespace(**raw_pc)
|
||||
|
||||
checkout_data = await sumup_get_checkout(order.sumup_checkout_id, page_config=page_config)
|
||||
order.sumup_status = checkout_data.get("status") or order.sumup_status
|
||||
sumup_status = (order.sumup_status or "").upper()
|
||||
|
||||
if sumup_status == "PAID":
|
||||
if order.status != "paid":
|
||||
order.status = "paid"
|
||||
await call_action("events", "confirm-entries-for-order", payload={
|
||||
"order_id": order.id, "user_id": order.user_id,
|
||||
"session_id": order.session_id,
|
||||
})
|
||||
await call_action("events", "confirm-tickets-for-order", payload={
|
||||
"order_id": order.id,
|
||||
})
|
||||
|
||||
page_post_id = page_config.container_id if page_config else None
|
||||
await call_action("cart", "clear-cart-for-order", payload={
|
||||
"user_id": order.user_id,
|
||||
"session_id": order.session_id,
|
||||
"page_post_id": page_post_id,
|
||||
})
|
||||
|
||||
await emit_activity(
|
||||
session,
|
||||
activity_type="rose:OrderPaid",
|
||||
actor_uri="internal:orders",
|
||||
object_type="rose:Order",
|
||||
object_data={
|
||||
"order_id": order.id,
|
||||
"user_id": order.user_id,
|
||||
},
|
||||
source_type="order",
|
||||
source_id=order.id,
|
||||
)
|
||||
elif sumup_status == "FAILED":
|
||||
order.status = "failed"
|
||||
else:
|
||||
order.status = sumup_status.lower() or order.status
|
||||
|
||||
await session.flush()
|
||||
147
orders/services/checkout.py
Normal file
147
orders/services/checkout.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user