import secrets from collections import defaultdict from datetime import datetime, timedelta, timezone from sqlalchemy import select from sqlalchemy.orm import Session, joinedload from app.core.config import get_settings from app.core.errors import AppError from app.models.catalog import Event, TicketType from app.models.enums import EventStatus, OrderStatus, TicketStatus, TicketTypeStatus from app.models.orders import Order, OrderItem, Ticket from app.models.user import User from app.schemas.orders import OrderCreate from app.services.payments import get_payment_gateway from app.services.tickets import generate_qr_token class InsufficientInventoryError(AppError): def __init__(self, message: str = "Not enough tickets available") -> None: super().__init__(message, status_code=409) def _utcnow() -> datetime: return datetime.now(timezone.utc) def _remaining(ticket_type: TicketType) -> int: return max(ticket_type.quantity_total - ticket_type.quantity_sold, 0) def _ticket_type_public_row(ticket_type: TicketType) -> dict: return { "id": ticket_type.id, "event_id": ticket_type.event_id, "name": ticket_type.name, "description": ticket_type.description, "price_paise": ticket_type.price_paise, "status": ticket_type.status, "sort_order": ticket_type.sort_order, "remaining_quantity": _remaining(ticket_type), } def list_event_ticket_types(db: Session, event_id: int) -> list[dict]: event = db.get(Event, event_id) if not event or event.status != EventStatus.published: raise AppError("Event not found", status_code=404) rows = db.scalars( select(TicketType) .where(TicketType.event_id == event_id) .order_by(TicketType.sort_order.asc(), TicketType.id.asc()) ).all() return [_ticket_type_public_row(row) for row in rows] def _generate_ticket_code() -> str: return f"DC-{secrets.token_hex(8).upper()}" def expire_stale_pending_orders(db: Session) -> None: settings = get_settings() cutoff = _utcnow() stale_orders = list( db.scalars( select(Order) .where( Order.status == OrderStatus.pending, Order.expires_at.is_not(None), Order.expires_at < cutoff, ) .with_for_update() ).all() ) for order in stale_orders: cancel_pending_order(db, order, status=OrderStatus.failed) def get_order_by_provider_order_id(db: Session, provider_order_id: str) -> Order | None: return db.scalar( select(Order) .options(joinedload(Order.items)) .where(Order.provider_order_id == provider_order_id) ) def get_order_by_idempotency_key(db: Session, key: str) -> Order | None: return db.scalar( select(Order) .options(joinedload(Order.items)) .where(Order.idempotency_key == key) ) def _reserve_inventory( db: Session, body: OrderCreate, ) -> tuple[int, list[tuple[TicketType, int, int]]]: line_map: dict[int, int] = defaultdict(int) for line in body.items: line_map[line.ticket_type_id] += line.quantity if not line_map: raise AppError("Order must include at least one ticket", status_code=400) ticket_type_ids = sorted(line_map.keys()) locked_types = list( db.scalars( select(TicketType) .where(TicketType.id.in_(ticket_type_ids)) .order_by(TicketType.id.asc()) .with_for_update() ).all() ) if len(locked_types) != len(ticket_type_ids): raise AppError("One or more ticket types are invalid", status_code=400) total_paise = 0 priced_lines: list[tuple[TicketType, int, int]] = [] for ticket_type in locked_types: qty = line_map[ticket_type.id] if ticket_type.event_id != body.event_id: raise AppError("Ticket type does not belong to this event", status_code=400) if ticket_type.status != TicketTypeStatus.active: raise InsufficientInventoryError(f"{ticket_type.name} is not available") remaining = _remaining(ticket_type) if qty > remaining: raise InsufficientInventoryError( f"Only {remaining} tickets left for {ticket_type.name}", ) line_total = ticket_type.price_paise * qty total_paise += line_total priced_lines.append((ticket_type, qty, line_total)) return total_paise, priced_lines def _apply_inventory_reservation(priced_lines: list[tuple[TicketType, int, int]]) -> None: for ticket_type, qty, _line_total in priced_lines: ticket_type.quantity_sold += qty if ticket_type.quantity_sold >= ticket_type.quantity_total: ticket_type.quantity_sold = min(ticket_type.quantity_sold, ticket_type.quantity_total) ticket_type.status = TicketTypeStatus.sold_out def _release_inventory_for_order(db: Session, order: Order) -> None: for item in order.items: ticket_type = db.get(TicketType, item.ticket_type_id) if not ticket_type: continue ticket_type.quantity_sold = max(ticket_type.quantity_sold - item.quantity, 0) if ticket_type.status == TicketTypeStatus.sold_out and _remaining(ticket_type) > 0: ticket_type.status = TicketTypeStatus.active def create_order( db: Session, user: User, body: OrderCreate, *, idempotency_key: str | None = None, ) -> Order: expire_stale_pending_orders(db) if idempotency_key: existing = get_order_by_idempotency_key(db, idempotency_key) if existing: return existing event = db.get(Event, body.event_id) if not event or event.status != EventStatus.published: raise AppError("Event not found or not available for purchase", status_code=404) total_paise, priced_lines = _reserve_inventory(db, body) settings = get_settings() gateway = get_payment_gateway(settings) order = Order( user_id=user.id, event_id=body.event_id, status=OrderStatus.pending, total_paise=total_paise, currency="INR", idempotency_key=idempotency_key, expires_at=_utcnow() + timedelta(minutes=settings.order_payment_timeout_minutes), ) db.add(order) db.flush() for ticket_type, qty, line_total in priced_lines: db.add( OrderItem( order_id=order.id, ticket_type_id=ticket_type.id, quantity=qty, unit_price_paise=ticket_type.price_paise, line_total_paise=line_total, ) ) _apply_inventory_reservation(priced_lines) provider_order = gateway.create_provider_order( amount_paise=total_paise, currency="INR", receipt=str(order.id), notes={"desi_clans_order_id": str(order.id), "user_id": str(user.id)}, ) order.provider_order_id = provider_order.provider_order_id order.payment_reference = provider_order.provider_order_id try: db.commit() except Exception: db.rollback() raise db.refresh(order) _ = order.items return order def fulfill_order_payment(db: Session, order: Order, *, provider_payment_id: str) -> Order: if order.status == OrderStatus.paid: return order existing_ticket = db.scalar(select(Ticket).where(Ticket.order_id == order.id).limit(1)) if not existing_ticket: items = db.scalars(select(OrderItem).where(OrderItem.order_id == order.id)).all() issued: list[Ticket] = [] for item in items: for _ in range(item.quantity): issued.append( Ticket( code=_generate_ticket_code(), qr_token=generate_qr_token(), order_id=order.id, order_item_id=item.id, ticket_type_id=item.ticket_type_id, event_id=order.event_id, user_id=order.user_id, status=TicketStatus.valid, ) ) db.add_all(issued) order.status = OrderStatus.paid order.provider_payment_id = provider_payment_id order.paid_at = _utcnow() order.expires_at = None db.commit() db.refresh(order) _ = order.items return order def cancel_pending_order( db: Session, order: Order, *, status: OrderStatus = OrderStatus.cancelled, ) -> Order: if order.status != OrderStatus.pending: raise AppError("Only pending orders can be cancelled", status_code=409) _release_inventory_for_order(db, order) order.status = status order.expires_at = None db.commit() db.refresh(order) return order def get_order_for_user(db: Session, order_id: int, user_id: int) -> Order: expire_stale_pending_orders(db) order = db.scalar( select(Order) .options(joinedload(Order.items)) .where(Order.id == order_id, Order.user_id == user_id) ) if not order: raise AppError("Order not found", status_code=404) return order def list_user_tickets(db: Session, user_id: int) -> list[Ticket]: return list( db.scalars( select(Ticket) .options( joinedload(Ticket.order_item).joinedload(OrderItem.ticket_type), joinedload(Ticket.order).joinedload(Order.event), ) .where(Ticket.user_id == user_id) .order_by(Ticket.created_at.desc()) ) .unique() .all() ) def get_ticket_for_user(db: Session, ticket_id: int, user_id: int) -> Ticket: ticket = db.scalar( select(Ticket) .options( joinedload(Ticket.order_item).joinedload(OrderItem.ticket_type), joinedload(Ticket.order).joinedload(Order.event), ) .where(Ticket.id == ticket_id, Ticket.user_id == user_id) ) if not ticket: raise AppError("Ticket not found", status_code=404) return ticket def ticket_to_public(ticket: Ticket) -> dict: ticket_type_name = None event_title = None if ticket.order_item and ticket.order_item.ticket_type: ticket_type_name = ticket.order_item.ticket_type.name if ticket.order and ticket.order.event: event_title = ticket.order.event.title return { "id": ticket.id, "code": ticket.code, "order_id": ticket.order_id, "ticket_type_id": ticket.ticket_type_id, "event_id": ticket.event_id, "status": ticket.status, "created_at": ticket.created_at, "ticket_type_name": ticket_type_name, "event_title": event_title, }