Session 9 - Ticket Reservation

As this order is comming to an end, we are completing the functionality of application with the vital feature of reserving tickets

Miscellaneous / Fixes

  • Fix GUID generation and async handling in ticket creation. (Use Guid.NewGuid() instead of new Guid())
SerialNumber = Guid.NewGuid().ToString("N")
  • Make SerialNumber, TicketOrderId, BaseAmount publicly settable in DTOs if they’re private.
  • Check Transaction and make sure SerialNumber is not a required property.

Branching

  • Create the feature/ticket-reservation branch based on develop

Ticket Ordering System

🧱 Domain and Infrastructure Setup

  • Check out new ERD: Here
  • Add migrations for the above database changes.

πŸ§‘β€πŸ’Ό Path to Service Layer

  • Create DTOs CreateTravellerTicketDto and CreateTicketOrderDto and the mappings:
public class CreateTravellerTicketDto
{
    public long Id { get; set; }
    public long CreatorId { get; set; }
 
    [Required(ErrorMessage = "First name is required")]
    public required string FirstName { get; set; }
 
    [Required(ErrorMessage = "Last name is required")]
    public required string LastName { get; set; }
 
    [Required(ErrorMessage = "Id number is required")]
    [RegularExpression(@"^\d{10}$", ErrorMessage = "National ID number must be exactly 10 digits")]
    public required string IdNumber { get; set; }
 
    [Required(ErrorMessage = "Gender should be identified")]
    public required short GenderId { get; set; }
 
    [Required(ErrorMessage = "Phone number is required")]
    [Phone(ErrorMessage = "Invalid phone number format")]
    public required string PhoneNumber { get; set; }
 
    [Required(ErrorMessage = "Birth date is required")]
    public DateTime BirthDate { get; set; }
 
    public long? SeatId { get; set; }
    public bool IsVIP { get; set; }
    public string? Description { get; set; }
}
public class CreateTicketOrderDto
{
    public long TransportationId { get; set; }
    public List<CreateTravellerTicketDto> MyProperty { get; set; }
}
 

Note: If you have the Coupon feature in your project, then add a CouponCode property in CreateTicketOrderDto.

  • Modify SeatRepository to add method GetSeatsByVehicleIdAsync
public Task<List<Seat>> GetSeatsByVehicleIdAsync(long vehicleId)
{
    var seats = DbSet
        .Include(s => s.Vehicle)
        .Include(s => s.Tickets).ThenInclude(t => t.Traveler)
        .Where(s => s.VehicleId == vehicleId).ToListAsync();
    return seats;
}
  • Add Enum for TicketStatus, VehicleType and TransactionType
public enum TicketStatusEnum
{
    Reserved = 1,
    Paid = 2,
    CancelledByUser = 3,
    CancelledBySystem = 4,
    Used = 5,
    Expired = 6
}
public enum VehicleTypeEnum
{
    Airplane = 1,
    Train = 2,
    Bus = 3
}
public enum TransactionTypeEnum
{
    Deposit = 1,
    Withdraw = 2
}
  • Add the lock-service to lock the transportation through reservation, then register it:
public class TransportationLockService : ITransportationLockService
{
    private readonly ConcurrentDictionary<long, SemaphoreSlim> _locks = new();
 
    public async Task<IDisposable> AcquireLockAsync(long transportationId)
    {
        var semaphore = _locks.GetOrAdd(transportationId, new SemaphoreSlim(1, 1));
        await semaphore.WaitAsync();
        return new Releaser(() => semaphore.Release());
    }
 
    private class Releaser : IDisposable
    {
        private readonly Action _release;
 
        public Releaser(Action release)
        {
            _release = release;
        }
 
        public void Dispose()
        {
            _release();
        }
    }
}
  • Add method CreateAsync in TransportationService
public async Task<Result<long>> CreateAsync(long accountId, TransactionDto dto)
{
    Transaction transaction = new();
    _mapper.Map(dto, transaction);
    transaction.AccountId = accountId;
 
    await _transactionRepository.InsertAsync(transaction);
    await _unitOfWork.CompleteAsync();
    return Result<long>.Success(transaction.Id);
}
  • Add method PayForTicketOrderAsync in AccountService
public async Task<Result<long>> PayForTicketOrderAsync(long accountId, long ticketOrderId, decimal baseAmount, decimal finalAmount)
{
    var account = await _accountRepository.GetByIdAsync(accountId);
    if (account == null)
    {
        return Result<long>.Error(0, "Account not found");
    }
 
    if (account.Balance < finalAmount)
    {
        return Result<long>.Error(0, "Not enough money");
    }
 
    account.Withdraw(finalAmount);
    _accountRepository.Update(account);
    await _unitOfWork.CompleteAsync();
 
    TransactionDto dto = new()
    {
        CreatedAt = DateTime.UtcNow,
        Description = "Payment for ticket order #" + ticketOrderId + " at " + DateTime.UtcNow,
        BaseAmount = baseAmount,
        FinalAmount = finalAmount,
        SerialNumber = Guid.NewGuid().ToString("N"),
        TicketOrderId = ticketOrderId,
        TransactionTypeId = (int)TransactionTypeEnum.Withdraw,
        TransactionType = TransactionTypeEnum.Withdraw.ToString()
    };
 
    return await _transactionService.CreateAsync(accountId, dto);
}
  • Create ITicketOrderService, TicketOrderService and implement CreateTicketOrderAsync
public async Task<Result<long>> CreateTicketOrderAsync(long accountId, CreateTicketOrderDto dto)
{
    // get the account
    var account = await _accountRepository.GetByIdAsync(accountId);
    if (account == null)
    {
        return Result<long>.Error(0, "Account not found");
    }
 
    // get the transportation
    var transportation = await _transportationRepository.GetByIdAsync(dto.TransportationId);
    if (transportation == null)
    {
        return Result<long>.Error(0, "Transportation not found");
    }
 
    // lock the transportation through reservation
    using (await _transportationLockService.AcquireLockAsync(dto.TransportationId))
    {
        var baseAmount = transportation.BasePrice * dto.Travellers.Count;
        if (account.Balance < baseAmount)
        {
            return Result<long>.Error(0, "Not enough money");
        }
        // check validity of transportation
        var checkSeats = ValidateTransportationAndSeats(transportation, dto.Travellers);
        if (!string.IsNullOrEmpty(checkSeats))
        {
            return Result<long>.Error(0, checkSeats);
        }
 
        var finalAmount = baseAmount;
        await AssignSeatsIfDynamic(transportation.VehicleId, dto.Travellers);
        await UpsertTravellers(accountId, dto.Travellers);
 
        // add the ticket order by the info we have
        TicketOrder ticketOrder = new()
        {
            BuyerId = accountId,
            CreatedAt = DateTime.UtcNow,
            Description = "",
            SerialNumber = Guid.NewGuid().ToString("N"),
            TransportationId = dto.TransportationId
        };
        await _ticketOrderRepository.InsertAsync(ticketOrder);
        
        foreach (var traveller in dto.Travellers)
        {
            if (!traveller.SeatId.HasValue)
            {
                return Result<long>.Error(0, "Seat ID is required for each traveller");
            }
 
            Ticket ticket = new()
            {
                CreatedAt = DateTime.UtcNow,
                Description = traveller.Description,
                SeatId = traveller.SeatId.Value,
                SerialNumber = Guid.NewGuid().ToString("N"),
                TicketOrder = ticketOrder,
                TicketStatusId = 1,
                TravelerId = traveller.Id,
            };
            await _ticketRepository.InsertAsync(ticket);
        }
 
        await _unitOfWork.CompleteAsync();
        await _accountService.PayForTicketOrderAsync(account.Id, ticketOrder.Id,
            baseAmount, finalAmount);
        return Result<long>.Success(ticketOrder.Id);
    }
}
  • Register TicketOrderService in DI container.

🎯 Controller Layer

  • Create TicketOrderController with the endpoint POST /CreateTicketOrder
[ApiController]
[Route("api/[controller]")]
[Authorize(Roles = "User")]
public class TicketOrderController : ControllerBase
{
    private readonly IUserContext _userContext;
    private readonly ITicketOrderService _ticketOrderService;
 
    public TicketOrderController(IUserContext userContext,
        ITicketOrderService ticketOrderService)
    {
        _userContext = userContext;
        _ticketOrderService = ticketOrderService;
    }
 
    [HttpPost("create-order")]
    public async Task<IActionResult> CreateTicketOrder([FromBody] CreateTicketOrderDto dto)
    {
        long accountId = _userContext.GetUserId();
        // check for account-id to be valid
        if (accountId <= 0)
        {
            return Unauthorized();
        }
 
        var result = await _ticketOrderService.CreateTicketOrderAsync(accountId, dto);
        if (result.IsSuccess)
        {
            return Ok(result.Data);
        }
 
        return result.Status switch
        {
            ResultStatus.Unauthorized => Unauthorized(result.ErrorMessage),
            ResultStatus.NotFound => NotFound(result.ErrorMessage),
            ResultStatus.ValidationError => BadRequest(result.ErrorMessage),
            _ => StatusCode(500, result.ErrorMessage)
        };
    }
}

πŸ—ƒοΈ Repository Layer

Implement there methods in TicketOrderRepository and its interface

  • FindAndLoadAllDetails
public Task<TicketOrder?> FindAndLoadAllDetailsAsync(long id)
{
    var ticketOrder = DbSet
        .Include(to => to.Transportation).ThenInclude(t => t.FromLocation).ThenInclude(fl => fl.City)
        .Include(to => to.Transportation).ThenInclude(t => t.ToLocation).ThenInclude(tl => tl.City)
        .Include(to => to.Tickets).ThenInclude(t => t.Traveler)
        .Where(to => to.Id == id).FirstOrDefaultAsync();
 
    return ticketOrder;
}
  • GetAllByBuyerId
public async Task<List<TicketOrder>> GetAllByBuyerId(long buyerId)
{
    var ticketOrders = await DbSet
        .Include(to => to.Transaction)
        .Include(to => to.Transportation).ThenInclude(t => t.FromLocation)
        .Include(to => to.Transportation).ThenInclude(t => t.ToLocation)
        .Include(to => to.Transportation).ThenInclude(t => t.Company)
        .Include(to => to.Transportation).ThenInclude(t => t.Vehicle)
        .Where(to => to.BuyerId == buyerId).ToListAsync();
    return ticketOrders;
}

Transportation and Seat Selection

  • Add TransportationSeatDto.
public class TransportationSeatDto
{
    public long Id { get; set; }
    public int Row { get; set; }
    public int Column { get; set; }
    public bool IsVIP { get; set; }
    public bool IsAvailable { get; set; }
    public string? Description { get; set; }
    public bool IsReserved { get; set; }
    public short? GenderId { get; set; }
}
  • Add mapping from Seat to TransportationSeatDto.
CreateMap<Seat, TransportationSeatDto>()
    .ForMember(dest => dest.IsReserved, opt => opt.MapFrom(src => src.Tickets.Any(t => t.TicketStatusId == (int)TicketStatusEnum.Reserved)))
    .ForMember(dest => dest.GenderId, opt => opt.MapFrom(src => src.Tickets.Any(t => t.TicketStatusId == (int)TicketStatusEnum.Reserved) ?
    src.Tickets.First(t => t.TicketStatusId == (int)TicketStatusEnum.Reserved).Traveler.GenderId : (short?)null));
  • Add method GetSeatsByVehicleId in ISeatRepository and implement it.
public Task<List<Seat>> GetSeatsByVehicleIdAsync(long vehicleId)
{
    var seats = DbSet
        .Include(s => s.Vehicle)
        .Include(s => s.Tickets).ThenInclude(t => t.Traveler)
        .Where(s => s.VehicleId == vehicleId).ToListAsync();
    return seats;
}
  • Add GetTransportationSeatsAsync in ITransportationService and implement.
public async Task<Result<List<TransportationSeatDto>>> GetTransportationSeatsAsync(long transportationId)
{
    var transportation = await _transportationRepository.GetByIdAsync(transportationId);
	if (transportation == null)
	{
		return Result<List<TransportationSeatDto>>.Error(null, "Transportation not found"); 
	}
 
	var seats = await _seatRepository.GetSeatsByVehicleIdAsync(transportation.VehicleId);
	if (seats == null || seats.Count != 0)
	{
		return Result<List<TransportationSeatDto>>.Success(_mapper.Map<List<TransportationSeatDto>>(seats));
	}
 
	return Result<List<TransportationSeatDto>>.NotFound(null);
}
  • Add GetTransportationSeats endpoint in TransportationController.
[HttpGet("{transportationId}/seats")]
public async Task<IActionResult> GetTransportationSeats(long transportationId)
{
	var result = await _transportationService.GetTransportationSeatsAsync(transportationId);
    if (result.IsSuccess)
    {
		return Ok(result.Data);
    }
 
    return result.Status switch
    {
        ResultStatus.Unauthorized => Unauthorized(result.ErrorMessage),
        ResultStatus.NotFound => NotFound(result.ErrorMessage),
        ResultStatus.ValidationError => BadRequest(result.ErrorMessage),
        _ => StatusCode(500, result.ErrorMessage)
    };
}
  • Ensure RemainingCapacity is treated as calculated (ignored in EF, removed from schema).
public int RemainingCapacity => Vehicle.Capacity -
	TicketOrders?.SelectMany(to => to.Tickets)
	.Count(t => t.TicketStatusId == 1) ?? 0;

βœ… Ticket Review & Confirmation

  • Make sure you have TravlerTicketDto, mapped to β€œ with the details
public class TravellerTicketDto
{
    public long Id { get; set; }
    public required string SerialNumber { get; set; }
    public required string TravellerName { get; set; }
    public DateTime BirthDate { get; set; }
    public required string SeatNumber { get; set; }
    public required string TicketStatus { get; set; }
    public string? CompanionName { get; set; }
    public string? Description { get; set; }
}
CreateMap<Ticket, TravellerTicketDto>()
    .ForMember(dest => dest.Id, opt => opt.MapFrom(src => src.Id))
    .ForMember(dest => dest.TravellerName, opt => opt.MapFrom(src => src.Traveler != null ? $"{src.Traveler.FirstName} {src.Traveler.LastName}" : ""))
    .ForMember(dest => dest.SerialNumber, opt => opt.MapFrom(src => src.SerialNumber))
    .ForMember(dest => dest.TicketStatus, opt => opt.MapFrom(src => src.TicketStatus.Ttile))
    .ForMember(dest => dest.CompanionName, opt => opt.MapFrom(src => src.Companion != null ? $"{src.Companion.FirstName} {src.Companion.LastName}" : ""))
    .ForMember(dest => dest.Description, opt => opt.MapFrom(src => src.Description));
  • Implement GetTicketOrderDetails endpoint to fetch ticket summary, make sure to create required methods as well
[HttpGet("my-travels/{ticketOrderId}")]
public async Task<IActionResult> GetTravelDetails(long ticketOrderId)
{
    long accountId = _userContext.GetUserId();
    if (accountId <= 0)
    {
        return Unauthorized();
    }
 
    var result = await _accountService.GetTicketOrderDetailsAsync(accountId, ticketOrderId);
    if (result.IsSuccess)
    {
        return Ok(result.Data);
    }
 
    return result.Status switch
    {
        ResultStatus.Unauthorized => Unauthorized(result.ErrorMessage),
        ResultStatus.NotFound => NotFound(result.ErrorMessage),
        ResultStatus.ValidationError => BadRequest(result.ErrorMessage),
        _ => StatusCode(500, result.ErrorMessage)
    };
}
public async Task<Result<List<TravellerTicketDto>>> GetTicketOrderDetailsAsync(long accoundId, long ticketOrderid)
{
    var result = await _ticketRepository.GetTicketsByTicketOrderId(ticketOrderid);
    if (result != null)
    {
        if (result.Count > 0 && result.First().TicketOrder.BuyerId != accoundId)
        {
            return Result<List<TravellerTicketDto>>.Error(null, "Account unauthorized");
        }
 
        return Result<List<TravellerTicketDto>>.Success(_mapper.Map<List<TravellerTicketDto>>(result));
    }
 
    return Result<List<TravellerTicketDto>>.NotFound(null);
}
public async Task<List<Ticket>> GetTicketsByTicketOrderId(long ticketOrderId)
{
    var tickets = await DbSet
        .Include(t => t.Traveler)
        .Include(t => t.TicketStatus)
        .Include(t => t.Companion)
        .Include(t => t.Seat)
        .Include(t => t.TicketOrder)
        .Where(t => t.TicketOrderId == ticketOrderId).ToListAsync();
    return tickets;
}

Merge

  • Create a PR and merge the current branch with develop