first commit
This commit is contained in:
commit
34ea15a54d
246 changed files with 18660 additions and 0 deletions
335
backend/app/services/orders.py
Normal file
335
backend/app/services/orders.py
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
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,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue