# app/bp/cart/routes.py from __future__ import annotations from quart import Blueprint, g, request, render_template, redirect, url_for, make_response from sqlalchemy import select, update from sqlalchemy.orm import selectinload from models.market import Product, CartItem from models.order import Order, OrderItem from suma_browser.app.payments.sumup import create_checkout as sumup_create_checkout from .services import ( current_cart_identity, get_cart, total, clear_cart_for_order, get_calendar_cart_entries, # NEW calendar_total, # NEW 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 config import config from models.calendars import CalendarEntry # NEW from suma_browser.app.utils.htmx import is_htmx_request def register(url_prefix: str) -> Blueprint: bp = Blueprint("cart", __name__, url_prefix=url_prefix) # NOTE: load_cart moved to shared/cart_loader.py # and registered in shared/factory.py as an app-level before_request #@bp.context_processor #async def inject_root(): # return { # "total": total, # "calendar_total": calendar_total, # NEW helper # # } @bp.get("/") async def view_cart(): if not is_htmx_request(): # Normal browser request: full page with layout html = await render_template( "_types/cart/index.html", ) else: html = await render_template( "_types/cart/_oob_elements.html", ) return await make_response(html) @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) # htmx support (optional) if request.headers.get("HX-Request") == "true": return await view_cart() # normal POST: go to cart page return redirect(url_for("cart.view_cart")) @bp.post("/checkout/") async def checkout(): """Create an Order from the current cart and redirect to SumUp Hosted Checkout.""" # Build cart cart = await get_cart(g.s) calendar_entries = await get_calendar_cart_entries(g.s) if not cart and not calendar_entries: return redirect(url_for("cart.view_cart")) product_total = total(cart) or 0 calendar_amount = calendar_total(calendar_entries) or 0 cart_total = product_total + calendar_amount if cart_total <= 0: return redirect(url_for("cart.view_cart")) # Resolve per-page credentials try: page_config = await resolve_page_config(g.s, cart, calendar_entries) except ValueError as e: html = await render_template( "_types/cart/checkout_error.html", order=None, error=str(e), ) return await make_response(html, 400) # Create order from cart 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, ) # Set page_config on order if resolved if page_config: order.page_config_id = page_config.id # Build SumUp checkout details redirect_url = url_for("cart.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) webhook_base_url = url_for("cart.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, ) await clear_cart_for_order(g.s, order) 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) @bp.post("/checkout/webhook//") async def checkout_webhook(order_id: int): """ Webhook endpoint for SumUp CHECKOUT_STATUS_CHANGED events. Security: - Optional shared secret in ?token=... (checked against config sumup.webhook_secret) - We *always* verify the event by calling SumUp's API. """ # Optional shared secret check 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 # Look up our order result = await g.s.execute(select(Order).where(Order.id == order_id)) order = result.scalar_one_or_none() if not order: return "", 204 # Make sure the checkout id matches the one we stored if order.sumup_checkout_id and order.sumup_checkout_id != checkout_id: return "", 204 # Verify with SumUp 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) status = (order.status or "pending").lower() # Optionally refresh status from SumUp if order.sumup_checkout_id: try: await check_sumup_status(g.s, order) except Exception: status = status or "pending" calendar_entries = order.calendar_entries or [] await g.s.flush() html = await render_template( "_types/cart/checkout_return.html", order=order, status=status, calendar_entries=calendar_entries, ) return await make_response(html) return bp