335 lines
11 KiB
Python
335 lines
11 KiB
Python
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,
|
|
}
|