diff --git a/browser/templates/_types/cart/checkout_return.html b/browser/templates/_types/cart/checkout_return.html
index 326a469..b08a09d 100644
--- a/browser/templates/_types/cart/checkout_return.html
+++ b/browser/templates/_types/cart/checkout_return.html
@@ -47,8 +47,8 @@
{% endif %}
{% include '_types/order/_items.html' %}
{% include '_types/order/_calendar_items.html' %}
-
-
+ {% include '_types/order/_ticket_items.html' %}
+
{% if order.status == 'failed' and order %}
Your payment was not completed.
diff --git a/browser/templates/_types/order/_ticket_items.html b/browser/templates/_types/order/_ticket_items.html
new file mode 100644
index 0000000..ef06c0b
--- /dev/null
+++ b/browser/templates/_types/order/_ticket_items.html
@@ -0,0 +1,49 @@
+{# --- Tickets in this order --- #}
+ {% if order and order_tickets %}
+
+
+ Event tickets in this order
+
+
+
+
+ {% endif %}
\ No newline at end of file
diff --git a/contracts/dtos.py b/contracts/dtos.py
index b6dad4c..af1c8eb 100644
--- a/contracts/dtos.py
+++ b/contracts/dtos.py
@@ -55,6 +55,10 @@ class TicketDTO:
calendar_name: str | None = None
created_at: datetime | None = None
checked_in_at: datetime | None = None
+ entry_id: int | None = None
+ price: Decimal | None = None
+ order_id: int | None = None
+ calendar_container_id: int | None = None
@dataclass(frozen=True, slots=True)
@@ -127,3 +131,5 @@ class CartSummaryDTO:
calendar_count: int = 0
calendar_total: Decimal = Decimal("0")
items: list[CartItemDTO] = field(default_factory=list)
+ ticket_count: int = 0
+ ticket_total: Decimal = Decimal("0")
diff --git a/contracts/protocols.py b/contracts/protocols.py
index d5b2370..7580d0e 100644
--- a/contracts/protocols.py
+++ b/contracts/protocols.py
@@ -82,6 +82,31 @@ class CalendarService(Protocol):
self, session: AsyncSession, post_ids: list[int],
) -> dict[int, list[CalendarEntryDTO]]: ...
+ async def pending_tickets(
+ self, session: AsyncSession, *, user_id: int | None, session_id: str | None,
+ ) -> list[TicketDTO]: ...
+
+ async def tickets_for_page(
+ self, session: AsyncSession, page_id: int, *, user_id: int | None, session_id: str | None,
+ ) -> list[TicketDTO]: ...
+
+ async def claim_tickets_for_order(
+ self, session: AsyncSession, order_id: int, user_id: int | None,
+ session_id: str | None, page_post_id: int | None,
+ ) -> None: ...
+
+ async def confirm_tickets_for_order(
+ self, session: AsyncSession, order_id: int,
+ ) -> None: ...
+
+ async def get_tickets_for_order(
+ self, session: AsyncSession, order_id: int,
+ ) -> list[TicketDTO]: ...
+
+ async def adopt_tickets_for_user(
+ self, session: AsyncSession, user_id: int, session_id: str,
+ ) -> None: ...
+
@runtime_checkable
class MarketService(Protocol):
diff --git a/events/handlers/login_handlers.py b/events/handlers/login_handlers.py
index e798ef6..17ef493 100644
--- a/events/handlers/login_handlers.py
+++ b/events/handlers/login_handlers.py
@@ -16,9 +16,10 @@ async def on_user_logged_in(event: DomainEvent, session: AsyncSession) -> None:
if services.has("cart"):
await services.cart.adopt_cart_for_user(session, user_id, session_id)
- # Adopt calendar entries (if calendar service is registered)
+ # Adopt calendar entries and tickets (if calendar service is registered)
if services.has("calendar"):
await services.calendar.adopt_entries_for_user(session, user_id, session_id)
+ await services.calendar.adopt_tickets_for_user(session, user_id, session_id)
register_handler("user.logged_in", on_user_logged_in)
diff --git a/services/calendar_impl.py b/services/calendar_impl.py
index 048d39d..1f749b9 100644
--- a/services/calendar_impl.py
+++ b/services/calendar_impl.py
@@ -5,6 +5,8 @@ calendar-domain tables on behalf of other domains.
"""
from __future__ import annotations
+from decimal import Decimal
+
from sqlalchemy import select, update, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -51,6 +53,12 @@ def _ticket_to_dto(ticket: Ticket) -> TicketDTO:
entry = getattr(ticket, "entry", None)
tt = getattr(ticket, "ticket_type", None)
cal = getattr(entry, "calendar", None) if entry else None
+ # Price: ticket type cost if available, else entry ticket_price
+ price = None
+ if tt and tt.cost is not None:
+ price = tt.cost
+ elif entry and entry.ticket_price is not None:
+ price = entry.ticket_price
return TicketDTO(
id=ticket.id,
code=ticket.code,
@@ -62,6 +70,10 @@ def _ticket_to_dto(ticket: Ticket) -> TicketDTO:
calendar_name=cal.name if cal else None,
created_at=ticket.created_at,
checked_in_at=ticket.checked_in_at,
+ entry_id=entry.id if entry else None,
+ price=price,
+ order_id=ticket.order_id,
+ calendar_container_id=cal.container_id if cal else None,
)
@@ -356,3 +368,128 @@ class SqlCalendarService:
.where(*filters)
.values(state="provisional")
)
+
+ # -- ticket methods -------------------------------------------------------
+
+ def _ticket_query_options(self):
+ return [
+ selectinload(Ticket.entry).selectinload(CalendarEntry.calendar),
+ selectinload(Ticket.ticket_type),
+ ]
+
+ async def pending_tickets(
+ self, session: AsyncSession, *, user_id: int | None, session_id: str | None,
+ ) -> list[TicketDTO]:
+ """Reserved tickets for the given identity (cart line items)."""
+ filters = [Ticket.state == "reserved"]
+ if user_id is not None:
+ filters.append(Ticket.user_id == user_id)
+ elif session_id is not None:
+ filters.append(Ticket.session_id == session_id)
+ else:
+ return []
+
+ result = await session.execute(
+ select(Ticket)
+ .where(*filters)
+ .order_by(Ticket.created_at.asc())
+ .options(*self._ticket_query_options())
+ )
+ return [_ticket_to_dto(t) for t in result.scalars().all()]
+
+ async def tickets_for_page(
+ self, session: AsyncSession, page_id: int, *,
+ user_id: int | None, session_id: str | None,
+ ) -> list[TicketDTO]:
+ """Reserved tickets scoped to a page (via entry → calendar → container_id)."""
+ cal_ids = select(Calendar.id).where(
+ Calendar.container_type == "page",
+ Calendar.container_id == page_id,
+ Calendar.deleted_at.is_(None),
+ ).scalar_subquery()
+
+ entry_ids = select(CalendarEntry.id).where(
+ CalendarEntry.calendar_id.in_(cal_ids),
+ CalendarEntry.deleted_at.is_(None),
+ ).scalar_subquery()
+
+ filters = [
+ Ticket.state == "reserved",
+ Ticket.entry_id.in_(entry_ids),
+ ]
+ if user_id is not None:
+ filters.append(Ticket.user_id == user_id)
+ elif session_id is not None:
+ filters.append(Ticket.session_id == session_id)
+ else:
+ return []
+
+ result = await session.execute(
+ select(Ticket)
+ .where(*filters)
+ .order_by(Ticket.created_at.asc())
+ .options(*self._ticket_query_options())
+ )
+ return [_ticket_to_dto(t) for t in result.scalars().all()]
+
+ async def claim_tickets_for_order(
+ self, session: AsyncSession, order_id: int, user_id: int | None,
+ session_id: str | None, page_post_id: int | None,
+ ) -> None:
+ """Set order_id on reserved tickets at checkout."""
+ filters = [Ticket.state == "reserved"]
+ if user_id is not None:
+ filters.append(Ticket.user_id == user_id)
+ elif session_id is not None:
+ filters.append(Ticket.session_id == session_id)
+
+ if page_post_id is not None:
+ cal_ids = select(Calendar.id).where(
+ Calendar.container_type == "page",
+ Calendar.container_id == page_post_id,
+ Calendar.deleted_at.is_(None),
+ ).scalar_subquery()
+ entry_ids = select(CalendarEntry.id).where(
+ CalendarEntry.calendar_id.in_(cal_ids),
+ CalendarEntry.deleted_at.is_(None),
+ ).scalar_subquery()
+ filters.append(Ticket.entry_id.in_(entry_ids))
+
+ await session.execute(
+ update(Ticket).where(*filters).values(order_id=order_id)
+ )
+
+ async def confirm_tickets_for_order(
+ self, session: AsyncSession, order_id: int,
+ ) -> None:
+ """Reserved → confirmed on payment."""
+ await session.execute(
+ update(Ticket)
+ .where(Ticket.order_id == order_id, Ticket.state == "reserved")
+ .values(state="confirmed")
+ )
+
+ async def get_tickets_for_order(
+ self, session: AsyncSession, order_id: int,
+ ) -> list[TicketDTO]:
+ """Tickets for a given order (checkout return display)."""
+ result = await session.execute(
+ select(Ticket)
+ .where(Ticket.order_id == order_id)
+ .order_by(Ticket.created_at.asc())
+ .options(*self._ticket_query_options())
+ )
+ return [_ticket_to_dto(t) for t in result.scalars().all()]
+
+ async def adopt_tickets_for_user(
+ self, session: AsyncSession, user_id: int, session_id: str,
+ ) -> None:
+ """Migrate anonymous reserved tickets to user on login."""
+ result = await session.execute(
+ select(Ticket).where(
+ Ticket.session_id == session_id,
+ Ticket.state == "reserved",
+ )
+ )
+ for ticket in result.scalars().all():
+ ticket.user_id = user_id
diff --git a/services/cart_impl.py b/services/cart_impl.py
index 84dd40f..1438bfa 100644
--- a/services/cart_impl.py
+++ b/services/cart_impl.py
@@ -93,6 +93,23 @@ class SqlCartService:
calendar_count = len(cal_entries)
calendar_total = sum(Decimal(str(e.cost or 0)) for e in cal_entries if e.cost is not None)
+ # --- tickets ---
+ if page_post_id is not None:
+ tickets = await services.calendar.tickets_for_page(
+ session, page_post_id,
+ user_id=user_id,
+ session_id=session_id,
+ )
+ else:
+ tickets = await services.calendar.pending_tickets(
+ session,
+ user_id=user_id,
+ session_id=session_id,
+ )
+
+ ticket_count = len(tickets)
+ ticket_total = sum(Decimal(str(t.price or 0)) for t in tickets)
+
items = [_item_to_dto(ci) for ci in cart_items]
return CartSummaryDTO(
@@ -101,6 +118,8 @@ class SqlCartService:
calendar_count=calendar_count,
calendar_total=calendar_total,
items=items,
+ ticket_count=ticket_count,
+ ticket_total=ticket_total,
)
async def cart_items(
diff --git a/services/stubs.py b/services/stubs.py
index 1f4c9f7..db3b38c 100644
--- a/services/stubs.py
+++ b/services/stubs.py
@@ -98,6 +98,37 @@ class StubCalendarService:
) -> dict[int, list[CalendarEntryDTO]]:
return {}
+ async def pending_tickets(
+ self, session: AsyncSession, *, user_id: int | None, session_id: str | None,
+ ) -> list[TicketDTO]:
+ return []
+
+ async def tickets_for_page(
+ self, session: AsyncSession, page_id: int, *, user_id: int | None, session_id: str | None,
+ ) -> list[TicketDTO]:
+ return []
+
+ async def claim_tickets_for_order(
+ self, session: AsyncSession, order_id: int, user_id: int | None,
+ session_id: str | None, page_post_id: int | None,
+ ) -> None:
+ pass
+
+ async def confirm_tickets_for_order(
+ self, session: AsyncSession, order_id: int,
+ ) -> None:
+ pass
+
+ async def get_tickets_for_order(
+ self, session: AsyncSession, order_id: int,
+ ) -> list[TicketDTO]:
+ return []
+
+ async def adopt_tickets_for_user(
+ self, session: AsyncSession, user_id: int, session_id: str,
+ ) -> None:
+ pass
+
class StubMarketService:
async def marketplaces_for_container(