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 ofnew Guid()
)
SerialNumber = Guid.NewGuid().ToString("N")
- Make
SerialNumber
,TicketOrderId
,BaseAmount
publicly settable in DTOs if theyβre private. - Check
Transaction
and make sureSerialNumber
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
andCreateTicketOrderDto
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
inTransportationService
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
inAccountService
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 implementCreateTicketOrderAsync
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 endpointPOST /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
toTransportationSeatDto
.
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
inISeatRepository
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
inITransportationService
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 inTransportationController
.
[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