# bp/cart/global_routes.py — Global cart routes (webhook, return, add) from __future__ import annotations from quart import Blueprint, g, request, render_template, redirect, url_for, make_response from sqlalchemy import select from shared.models.market import CartItem from shared.models.order import Order from shared.infrastructure.actions import call_action from .services import ( current_cart_identity, get_cart, total, get_calendar_cart_entries, calendar_total, get_ticket_cart_entries, ticket_total, check_sumup_status, ) from .services.checkout import ( find_or_create_cart_item, create_order_from_cart, resolve_page_config, build_sumup_description, build_sumup_reference, build_webhook_url, validate_webhook_secret, get_order_with_details, ) from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout from shared.browser.app.csrf import csrf_exempt def register(url_prefix: str) -> Blueprint: bp = Blueprint("cart_global", __name__, url_prefix=url_prefix) @bp.post("/add//") async def add_to_cart(product_id: int): ident = current_cart_identity() cart_item = await find_or_create_cart_item( g.s, product_id, ident["user_id"], ident["session_id"], ) if not cart_item: return await make_response("Product not found", 404) if request.headers.get("HX-Request") == "true": # Redirect to overview for HTMX return redirect(url_for("cart_overview.overview")) return redirect(url_for("cart_overview.overview")) @bp.post("/quantity//") async def update_quantity(product_id: int): ident = current_cart_identity() form = await request.form count = int(form.get("count", 0)) filters = [ CartItem.deleted_at.is_(None), CartItem.product_id == product_id, ] if ident["user_id"] is not None: filters.append(CartItem.user_id == ident["user_id"]) else: filters.append(CartItem.session_id == ident["session_id"]) existing = await g.s.scalar(select(CartItem).where(*filters)) if existing: existing.quantity = max(count, 0) await g.s.flush() resp = await make_response("", 200) resp.headers["HX-Refresh"] = "true" return resp @bp.post("/ticket-quantity/") async def update_ticket_quantity(): """Adjust reserved ticket count (+/- pattern, like products).""" ident = current_cart_identity() form = await request.form entry_id = int(form.get("entry_id", 0)) count = max(int(form.get("count", 0)), 0) tt_raw = (form.get("ticket_type_id") or "").strip() ticket_type_id = int(tt_raw) if tt_raw else None await call_action("events", "adjust-ticket-quantity", payload={ "entry_id": entry_id, "count": count, "user_id": ident["user_id"], "session_id": ident["session_id"], "ticket_type_id": ticket_type_id, }) resp = await make_response("", 200) resp.headers["HX-Refresh"] = "true" return resp @bp.post("/delete//") async def delete_item(product_id: int): ident = current_cart_identity() filters = [ CartItem.deleted_at.is_(None), CartItem.product_id == product_id, ] if ident["user_id"] is not None: filters.append(CartItem.user_id == ident["user_id"]) else: filters.append(CartItem.session_id == ident["session_id"]) existing = await g.s.scalar(select(CartItem).where(*filters)) if existing: await g.s.delete(existing) await g.s.flush() resp = await make_response("", 200) resp.headers["HX-Refresh"] = "true" return resp @bp.post("/checkout/") async def checkout(): """Legacy global checkout (for orphan items without page scope).""" cart = await get_cart(g.s) calendar_entries = await get_calendar_cart_entries(g.s) tickets = await get_ticket_cart_entries(g.s) if not cart and not calendar_entries and not tickets: return redirect(url_for("cart_overview.overview")) product_total = total(cart) or 0 calendar_amount = calendar_total(calendar_entries) or 0 ticket_amount = ticket_total(tickets) or 0 cart_total = product_total + calendar_amount + ticket_amount if cart_total <= 0: return redirect(url_for("cart_overview.overview")) try: page_config = await resolve_page_config(g.s, cart, calendar_entries, tickets) except ValueError as e: html = await render_template( "_types/cart/checkout_error.html", order=None, error=str(e), ) return await make_response(html, 400) ident = current_cart_identity() order = await create_order_from_cart( g.s, cart, calendar_entries, ident.get("user_id"), ident.get("session_id"), product_total, calendar_amount, ticket_total=ticket_amount, ) if page_config: order.page_config_id = page_config.id redirect_url = url_for("cart_global.checkout_return", order_id=order.id, _external=True) order.sumup_reference = build_sumup_reference(order.id, page_config=page_config) description = build_sumup_description(cart, order.id, ticket_count=len(tickets)) webhook_base_url = url_for("cart_global.checkout_webhook", order_id=order.id, _external=True) webhook_url = build_webhook_url(webhook_base_url) checkout_data = await sumup_create_checkout( order, redirect_url=redirect_url, webhook_url=webhook_url, description=description, page_config=page_config, ) order.sumup_checkout_id = checkout_data.get("id") order.sumup_status = checkout_data.get("status") order.description = checkout_data.get("description") hosted_cfg = checkout_data.get("hosted_checkout") or {} hosted_url = hosted_cfg.get("hosted_checkout_url") or checkout_data.get("hosted_checkout_url") order.sumup_hosted_url = hosted_url await g.s.flush() if not hosted_url: html = await render_template( "_types/cart/checkout_error.html", order=order, error="No hosted checkout URL returned from SumUp.", ) return await make_response(html, 500) return redirect(hosted_url) @csrf_exempt @bp.post("/checkout/webhook//") async def checkout_webhook(order_id: int): """Webhook endpoint for SumUp CHECKOUT_STATUS_CHANGED events.""" if not validate_webhook_secret(request.args.get("token")): return "", 204 try: payload = await request.get_json() except Exception: payload = None if not isinstance(payload, dict): return "", 204 if payload.get("event_type") != "CHECKOUT_STATUS_CHANGED": return "", 204 checkout_id = payload.get("id") if not checkout_id: return "", 204 result = await g.s.execute(select(Order).where(Order.id == order_id)) order = result.scalar_one_or_none() if not order: return "", 204 if order.sumup_checkout_id and order.sumup_checkout_id != checkout_id: return "", 204 try: await check_sumup_status(g.s, order) except Exception: pass return "", 204 @bp.get("/checkout/return//") async def checkout_return(order_id: int): """Handle the browser returning from SumUp after payment.""" order = await get_order_with_details(g.s, order_id) if not order: html = await render_template( "_types/cart/checkout_return.html", order=None, status="missing", calendar_entries=[], ) return await make_response(html) # Resolve page/market slugs so product links render correctly if order.page_config_id: from shared.infrastructure.data_client import fetch_data from shared.contracts.dtos import CalendarEntryDTO, TicketDTO, dto_from_dict raw_pc = await fetch_data("blog", "page-config-by-id", params={"id": order.page_config_id}, required=False) post = await fetch_data("blog", "post-by-id", params={"id": raw_pc["container_id"]}, required=False) if raw_pc else None if post: g.page_slug = post["slug"] # Fetch marketplace slug from market service mps = await fetch_data( "market", "marketplaces-for-container", params={"type": "page", "id": post["id"]}, required=False, ) or [] if mps: g.market_slug = mps[0].get("slug") if order.sumup_checkout_id: try: await check_sumup_status(g.s, order) except Exception: pass status = (order.status or "pending").lower() from shared.infrastructure.data_client import fetch_data from shared.contracts.dtos import CalendarEntryDTO, TicketDTO, dto_from_dict raw_entries = await fetch_data("events", "entries-for-order", params={"order_id": order.id}, required=False) or [] calendar_entries = [dto_from_dict(CalendarEntryDTO, e) for e in raw_entries] raw_tickets = await fetch_data("events", "tickets-for-order", params={"order_id": order.id}, required=False) or [] order_tickets = [dto_from_dict(TicketDTO, t) for t in raw_tickets] await g.s.flush() html = await render_template( "_types/cart/checkout_return.html", order=order, status=status, calendar_entries=calendar_entries, order_tickets=order_tickets, ) return await make_response(html) return bp