using Fitness_core_BE.DTOs.Booking; using Fitness_core_BE.Helpers; using Fitness_core_BE.Infra.UoW; using Fitness_core_BE.Interfaces; using Fitness_core_BE.Interfaces.Services; using Fitness_core_BE.Models; using System.Security.Cryptography; namespace Fitness_core_BE.Services { public class BookingService : IBookingService { private readonly IBookingRepository _bookingRepository; private readonly IFacilityRepository _facilityRepository; private readonly ICustomerSubscriptionRepository _customerSubscriptionRepository; private readonly IUnitOfWork _uow; private readonly UserRoleHelper _userRoleHelper; public BookingService( IBookingRepository bookingRepository, IFacilityRepository facilityRepository, ICustomerSubscriptionRepository customerSubscriptionRepository, IUnitOfWork uow, UserRoleHelper userRoleHelper) { _bookingRepository = bookingRepository; _facilityRepository = facilityRepository; _customerSubscriptionRepository = customerSubscriptionRepository; _uow = uow; _userRoleHelper = userRoleHelper; } public async Task CreateMemberBooking(object payload, HttpContext httpContext) { await _uow.BeginAsync(); try { var data = DeserializePayload.Deserialize(payload); var facilitySessionId = Guid.Parse(data["facilitySessionId"].GetString()!); var session = await _facilityRepository.GetFacilitySessionById(facilitySessionId) ?? throw new KeyNotFoundException("Facility session not found"); if (session.IsCancelled) throw new ArgumentException("This session has been cancelled"); if (session.StartTime <= DateTime.UtcNow) throw new ArgumentException("This session has already started"); var cusId = await _userRoleHelper.GetCusId(DataExtractor.GetUserIdFromContext(httpContext)); if (await _bookingRepository.CustomerHasActiveBookingForSession(facilitySessionId, cusId)) throw new ArgumentException("You already have a booking for this session"); var subscription = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(cusId) ?? throw new ArgumentException("No active membership plan found"); // Null SessionsPerCycle = unlimited-access plan (existing gym behavior) -- // no allowance to check. A set value caps how many Facility sessions this // cycle's plan covers; SessionsCarriedOver (from a GracePeriod rollover, set at // renewal approval -- see SubscriptionRenewalApprovalService.ApproveAsync) // extends that cap only until SessionsCarriedOverExpiresAt. if (subscription.CustomerPlan.SessionsPerCycle.HasValue) { var carriedOver = subscription.SessionsCarriedOverExpiresAt.HasValue && subscription.SessionsCarriedOverExpiresAt.Value < DateTime.UtcNow ? 0 : subscription.SessionsCarriedOver; var allowance = subscription.CustomerPlan.SessionsPerCycle.Value + carriedOver; if (subscription.SessionsUsedThisCycle >= allowance) throw new ArgumentException("You've used all the sessions included in your current plan cycle"); } var capacity = session.Capacity ?? session.Facility?.DefaultCapacity ?? 1; var bookedCount = await _facilityRepository.GetActiveBookingCount(facilitySessionId); if (bookedCount >= capacity) throw new ArgumentException("This session is fully booked"); var booking = new Booking { FKFacilitySessionId = facilitySessionId, FKClientId = session.FKClientId, IsGuest = false, FKCustomerId = cusId, FKCustomerSubscriptionId = subscription.SubscriptionId, PaymentStatus = "NotRequired", Status = "Confirmed" }; await _bookingRepository.CreateBooking(booking); if (subscription.CustomerPlan.SessionsPerCycle.HasValue) subscription.SessionsUsedThisCycle++; await _uow.CommitAsync(); return MapToDto(booking, session, includeOtp: false, BuildShareUrl(httpContext, booking.BookingId)); } catch { await _uow.RollbackAsync(); throw; } } public async Task CreateGuestBooking(object payload, HttpContext httpContext) { await _uow.BeginAsync(); try { var data = DeserializePayload.Deserialize(payload); var facilitySessionId = Guid.Parse(data["facilitySessionId"].GetString()!); var guestName = data["guestName"].GetString() ?? throw new ArgumentException("guestName is required"); var guestPhone = data["guestPhone"].GetString() ?? throw new ArgumentException("guestPhone is required"); var session = await _facilityRepository.GetFacilitySessionById(facilitySessionId) ?? throw new KeyNotFoundException("Facility session not found"); if (session.IsCancelled) throw new ArgumentException("This session has been cancelled"); if (session.StartTime <= DateTime.UtcNow) throw new ArgumentException("This session has already started"); var capacity = session.Capacity ?? session.Facility?.DefaultCapacity ?? 1; var bookedCount = await _facilityRepository.GetActiveBookingCount(facilitySessionId); if (bookedCount >= capacity) throw new ArgumentException("This session is fully booked"); var booking = new Booking { FKFacilitySessionId = facilitySessionId, FKClientId = session.FKClientId, IsGuest = true, GuestName = guestName, GuestPhone = guestPhone, OtpCode = GenerateOtp(), // Payment happens separately -- online (not wired up yet) or at the venue, // recorded via MarkGuestBookingPaid. The booking + OTP receipt exist either way. PaymentStatus = "Unpaid", Status = "Confirmed" }; await _bookingRepository.CreateBooking(booking); await _uow.CommitAsync(); // The only time the OTP is ever returned -- this response IS the guest's receipt. return MapToDto(booking, session, includeOtp: true, BuildShareUrl(httpContext, booking.BookingId)); } catch { await _uow.RollbackAsync(); throw; } } public async Task MarkGuestBookingPaid(object payload, HttpContext httpContext) { await _uow.BeginAsync(); try { var data = DeserializePayload.Deserialize(payload); var bookingId = Guid.Parse(data["bookingId"].GetString()!); var amountCharged = data["amountCharged"].GetDecimal(); var booking = await _bookingRepository.GetBookingById(bookingId) ?? throw new KeyNotFoundException("Booking not found"); if (!booking.IsGuest) throw new ArgumentException("Only guest bookings take a manual payment record"); booking.PaymentStatus = "Paid"; booking.AmountCharged = amountCharged; await _uow.CommitAsync(); return MapToDto(booking, booking.FacilitySession, includeOtp: false); } catch { await _uow.RollbackAsync(); throw; } } public async Task CheckInBooking(object payload, HttpContext httpContext) { await _uow.BeginAsync(); try { var data = DeserializePayload.Deserialize(payload); var otpCode = data["otpCode"].GetString() ?? throw new ArgumentException("otpCode is required"); var booking = await _bookingRepository.GetActiveBookingByOtp(otpCode) ?? throw new KeyNotFoundException("No confirmed booking found for this code"); if (booking.OtpVerifiedAt.HasValue) throw new ArgumentException("This booking has already been checked in"); booking.OtpVerifiedAt = DateTime.UtcNow; booking.CheckedInAt = DateTime.UtcNow; booking.Status = "CheckedIn"; await _uow.CommitAsync(); return MapToDto(booking, booking.FacilitySession, includeOtp: false); } catch { await _uow.RollbackAsync(); throw; } } public async Task CancelBooking(object payload, HttpContext httpContext) { await _uow.BeginAsync(); try { var data = DeserializePayload.Deserialize(payload); var bookingId = Guid.Parse(data["bookingId"].GetString()!); var booking = await _bookingRepository.GetBookingById(bookingId) ?? throw new KeyNotFoundException("Booking not found"); if (booking.Status == "Cancelled") throw new ArgumentException("This booking is already cancelled"); if (booking.Status == "CheckedIn") throw new ArgumentException("This booking has already been checked in and can't be cancelled"); booking.Status = "Cancelled"; // Give the session allowance back -- only member bookings draw one down. if (!booking.IsGuest && booking.FKCustomerSubscriptionId.HasValue) { var subscription = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(booking.FKCustomerId!.Value); if (subscription != null && subscription.SubscriptionId == booking.FKCustomerSubscriptionId.Value && subscription.CustomerPlan.SessionsPerCycle.HasValue && subscription.SessionsUsedThisCycle > 0) { subscription.SessionsUsedThisCycle--; } } await _uow.CommitAsync(); return MapToDto(booking, booking.FacilitySession, includeOtp: false); } catch { await _uow.RollbackAsync(); throw; } } public async Task GetMyBookings(object payload, HttpContext httpContext) { var cusId = await _userRoleHelper.GetCusId(DataExtractor.GetUserIdFromContext(httpContext)); var bookings = await _bookingRepository.GetBookingsForCustomer(cusId); return bookings.Select(b => MapToDto(b, b.FacilitySession, includeOtp: false)).ToList(); } public async Task GetSessionBookings(object payload, HttpContext httpContext) { var data = DeserializePayload.Deserialize(payload); var facilitySessionId = Guid.Parse(data["facilitySessionId"].GetString()!); var bookings = await _bookingRepository.GetBookingsForSession(facilitySessionId); // Staff view -- never expose guest OTPs here either; the door check-in flow reads // the OTP the guest already has, it doesn't need staff to look it up beforehand. return bookings.Select(b => MapToDto(b, b.FacilitySession, includeOtp: false)).ToList(); } public async Task GetBookingView(object payload) { var data = DeserializePayload.Deserialize(payload); var bookingId = Guid.Parse(data["bookingId"].GetString()!); var booking = await _bookingRepository.GetBookingById(bookingId); if (booking == null) return null; // The share link itself is the credential (an unguessable GUID) -- the OTP is only // withheld once it's already been used, same rule as every other read path. return MapToDto(booking, booking.FacilitySession, includeOtp: !booking.OtpVerifiedAt.HasValue); } private static string GenerateOtp() => RandomNumberGenerator.GetInt32(100000, 1000000).ToString(); // Built from the request itself (scheme/host), not a config value, so the link is // correct in every environment (localhost during dev, whatever domain in production) // without needing a separate "public base URL" setting kept in sync. private static string BuildShareUrl(HttpContext httpContext, Guid bookingId) => $"{httpContext.Request.Scheme}://{httpContext.Request.Host}/api/public/bookings/{bookingId}/view"; private static BookingDto MapToDto(Booking booking, FacilitySession? session, bool includeOtp, string? shareUrl = null) => new() { BookingId = booking.BookingId, FacilitySessionId = booking.FKFacilitySessionId, FacilityName = session?.Facility?.Name, SessionStartTime = session?.StartTime, SessionEndTime = session?.EndTime, IsGuest = booking.IsGuest, CustomerId = booking.FKCustomerId, GuestName = booking.GuestName, GuestPhone = booking.GuestPhone, OtpCode = includeOtp ? booking.OtpCode : null, OtpVerified = booking.OtpVerifiedAt.HasValue, PaymentStatus = booking.PaymentStatus, AmountCharged = booking.AmountCharged, Status = booking.Status, BookedAt = booking.BookedAt, CheckedInAt = booking.CheckedInAt, ShareUrl = shareUrl }; } }