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; using System.Text.Json; namespace Fitness_core_BE.Services { public class BookingService( IBookingRepository bookingRepository, IFacilityRepository facilityRepository, IRfidTagRepository rfidTagRepository, ICustomerSubscriptionRepository customerSubscriptionRepository, ICustomerRepository customerRepository, IUnitOfWork uow, UserRoleHelper userRoleHelper) : IBookingService { private readonly IBookingRepository _bookingRepository = bookingRepository; private readonly IFacilityRepository _facilityRepository = facilityRepository; private readonly IRfidTagRepository _rfidTagRepository = rfidTagRepository; private readonly ICustomerSubscriptionRepository _customerSubscriptionRepository = customerSubscriptionRepository; private readonly ICustomerRepository _customerRepository = customerRepository; private readonly IUnitOfWork _uow = uow; private readonly UserRoleHelper _userRoleHelper = userRoleHelper; // ---------- Scheduled-mode creation (existing behavior, now also stamping the // denormalized FKFacilityId/StartTime/EndTime every Booking carries) ---------- 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, FKFacilityId = session.FKFacilityId, Facility = session.Facility, StartTime = session.StartTime, EndTime = session.EndTime, 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, 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 customerId = await ResolveOptionalCustomerLinkAsync(data); 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, FKFacilityId = session.FKFacilityId, Facility = session.Facility, StartTime = session.StartTime, EndTime = session.EndTime, FKClientId = session.FKClientId, IsGuest = true, FKCustomerId = customerId, 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, includeOtp: true, BuildShareUrl(httpContext, booking.BookingId)); } catch { await _uow.RollbackAsync(); throw; } } // ---------- AdHoc-mode creation (new) ---------- public async Task CreateMemberAdhocBooking(object payload, HttpContext httpContext) { await _uow.BeginAsync(); try { var (facility, startTime, endTime, partySize, _) = await ValidateAdhocRequestAsync(payload); var cusId = await _userRoleHelper.GetCusId(DataExtractor.GetUserIdFromContext(httpContext)); var booking = new Booking { FKFacilitySessionId = null, FKFacilityId = facility.FacilityId, Facility = facility, StartTime = startTime, EndTime = endTime, PartySize = partySize, FKClientId = facility.FKClientId, IsGuest = false, FKCustomerId = cusId, // Simplifying assumption mirroring the existing Scheduled member-booking // path: membership covers facility use, no separate per-booking charge. PaymentStatus = "NotRequired", Status = "Confirmed" }; await _bookingRepository.CreateBooking(booking); await _uow.CommitAsync(); return MapToDto(booking, includeOtp: false, BuildShareUrl(httpContext, booking.BookingId)); } catch { await _uow.RollbackAsync(); throw; } } public async Task CreateGuestAdhocBooking(object payload, HttpContext httpContext) { await _uow.BeginAsync(); try { var (facility, startTime, endTime, partySize, pricePerHour) = await ValidateAdhocRequestAsync(payload); var data = DeserializePayload.Deserialize(payload); var guestName = data["guestName"].GetString() ?? throw new ArgumentException("guestName is required"); var guestPhone = data["guestPhone"].GetString() ?? throw new ArgumentException("guestPhone is required"); var customerId = await ResolveOptionalCustomerLinkAsync(data); var durationHours = (decimal)(endTime - startTime).TotalHours; var booking = new Booking { FKFacilitySessionId = null, FKFacilityId = facility.FacilityId, Facility = facility, StartTime = startTime, EndTime = endTime, PartySize = partySize, FKClientId = facility.FKClientId, IsGuest = true, FKCustomerId = customerId, GuestName = guestName, GuestPhone = guestPhone, OtpCode = GenerateOtp(), // Staff can override the facility's default rate per booking (e.g. a discount) // -- the pre-filled suggestion in the FE comes from facility.PricePerHour, but // the charge actually uses whatever rate was submitted. AmountCharged = Math.Round((pricePerHour ?? facility.PricePerHour) * durationHours, 2), PaymentStatus = "Unpaid", Status = "Confirmed" }; await _bookingRepository.CreateBooking(booking); await _uow.CommitAsync(); return MapToDto(booking, includeOtp: true, BuildShareUrl(httpContext, booking.BookingId)); } catch { await _uow.RollbackAsync(); throw; } } /// Shared validation for both AdHoc creation paths: facility must be AdHoc-mode, /// requested window must have a free unit. Locks the facility row for the duration of the /// caller's transaction so two concurrent claims on the same facility serialize instead of /// both reading "room available" before either commits. private async Task<(Facility facility, DateTime startTime, DateTime endTime, int? partySize, decimal? pricePerHour)> ValidateAdhocRequestAsync(object payload) { var data = DeserializePayload.Deserialize(payload); var facilityId = Guid.Parse(data["facilityId"].GetString()!); var startTime = DateTime.Parse(data["startTime"].GetString()!).ToUniversalTime(); var durationMinutes = data["durationMinutes"].GetInt32(); if (durationMinutes < 1) throw new ArgumentException("durationMinutes must be at least 1"); var endTime = startTime.AddMinutes(durationMinutes); int? partySize = data.ContainsKey("partySize") && data["partySize"].ValueKind != JsonValueKind.Null ? data["partySize"].GetInt32() : null; // Optional per-booking rate override -- the FE pre-fills this from the facility's // own PricePerHour but lets staff adjust it (e.g. a discount) before charging. decimal? pricePerHour = data.ContainsKey("pricePerHour") && data["pricePerHour"].ValueKind != JsonValueKind.Null ? data["pricePerHour"].GetDecimal() : null; if (pricePerHour.HasValue && pricePerHour.Value < 0) throw new ArgumentException("pricePerHour cannot be negative"); var facility = await _facilityRepository.GetFacilityById(facilityId) ?? throw new KeyNotFoundException("Facility not found"); if (facility.BookingMode != "AdHoc") throw new ArgumentException("This facility does not support ad-hoc booking"); if (!facility.IsActive) throw new ArgumentException("This facility is not active"); await _facilityRepository.LockFacilityForUpdateAsync(facilityId); var overlapping = await _facilityRepository.GetOverlappingAdhocBookingCountAsync(facilityId, startTime, endTime); if (overlapping >= facility.NumberOfUnits) throw new ArgumentException("No units are free for the requested time"); return (facility, startTime, endTime, partySize, pricePerHour); } /// Staff can optionally identify a walk-in guest as an already-registered member /// (search-and-pick, not free-typed) -- purely for identification/auto-fill on the FE, see /// CreateGuestBooking/CreateGuestAdhocBooking. Doesn't change IsGuest, PaymentStatus, or /// any membership handling; FKCustomerId is just an informational link layered on top of /// an otherwise-ordinary guest booking. private async Task ResolveOptionalCustomerLinkAsync(Dictionary data) { if (!data.ContainsKey("customerId") || data["customerId"].ValueKind == JsonValueKind.Null) return null; var customerId = Guid.Parse(data["customerId"].GetString()!); _ = await _customerRepository.GetTenantCustomerLinkById(customerId) ?? throw new KeyNotFoundException("Customer not found"); return customerId; } // ---------- Payment / check-in / lifecycle ---------- 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 paymentMethod = data.ContainsKey("paymentMethod") && data["paymentMethod"].ValueKind != JsonValueKind.Null ? data["paymentMethod"].GetString() ?? "Cash" : "Cash"; 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; booking.PaymentMethod = paymentMethod; booking.PaidAt = DateTime.UtcNow; await CreateBookingInvoiceAsync(booking, paymentMethod); await _uow.CommitAsync(); return MapToDto(booking, 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"); Guid? rfidTagId = data.ContainsKey("rfidTagId") && data["rfidTagId"].ValueKind != JsonValueKind.Null ? Guid.Parse(data["rfidTagId"].GetString()!) : null; var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); // Reconcile everything the passage of time has made stale for this client BEFORE // processing the new check-in, instead of a background job -- see // Booking.Status doc comment. If the booking being checked in is itself already // past its window, this sweep flips it to NoShow first, so the OTP lookup below // (Status == "Confirmed" only) correctly refuses it. await SweepOverdueBookingsAsync(clientId, DateTime.UtcNow); var booking = await _bookingRepository.GetActiveBookingByOtp(otpCode) ?? throw new KeyNotFoundException("No confirmed booking found for this code"); if (booking.FKClientId != clientId) throw new KeyNotFoundException("No confirmed booking found for this code"); if (booking.OtpVerifiedAt.HasValue) throw new ArgumentException("This booking has already been checked in"); if (rfidTagId.HasValue) { var tag = await _rfidTagRepository.GetTagById(rfidTagId.Value, clientId) ?? throw new KeyNotFoundException("RFID tag not found"); if (tag.FKClientId != booking.FKClientId) throw new KeyNotFoundException("RFID tag not found"); var now = DateTime.UtcNow; if (!await _bookingRepository.TryClaimRfidTagAsync(rfidTagId.Value, now)) throw new ArgumentException("This tag is no longer available -- pick another one."); booking.FKRfidTagId = rfidTagId.Value; booking.RfidAssignedAt = now; booking.RfidTag = tag; // for the immediate response's RfidTagLabel only } booking.OtpVerifiedAt = DateTime.UtcNow; booking.CheckedInAt = DateTime.UtcNow; booking.Status = "CheckedIn"; await _uow.CommitAsync(); return MapToDto(booking, includeOtp: false); } catch { await _uow.RollbackAsync(); throw; } } /// Lazily reconciles what a background job would otherwise have swept: Confirmed /// bookings whose window passed with no check-in become NoShow. Runs at the top of every /// check-in AND every checkout (Complete), across every facility for this client (not /// scoped to whichever facility is being acted on right now), so the whole roster stays /// accurate as long as staff keep interacting with bookings anywhere in the club. /// Deliberately does NOT auto-complete overdue CheckedIn bookings -- a guest booking can't /// be checked out while still Unpaid (see CompleteBooking), and nothing here can ask /// whether payment was collected, so those stay CheckedIn (tag stays InUse) until a staff /// member manually completes them. private async Task SweepOverdueBookingsAsync(Guid clientId, DateTime now) { await _bookingRepository.MarkOverdueConfirmedAsNoShowAsync(clientId, now); // Caller's _uow.CommitAsync() persists this alongside its own changes. } /// Snapshots a receipt the moment a booking's payment is recorded (from /// MarkGuestBookingPaid or the Complete checkout flow) -- a copy of the booking's numbers /// at that instant, not a live join, so it stays accurate even if the booking is later /// reassigned/rescheduled. Caller's _uow.CommitAsync() persists this alongside its own /// changes. private async Task CreateBookingInvoiceAsync(Booking booking, string paymentMethod) { await _bookingRepository.CreateBookingInvoice(new BookingInvoice { FKBookingId = booking.BookingId, FKClientId = booking.FKClientId, Amount = booking.AmountCharged, PaymentMethod = paymentMethod, GuestName = booking.GuestName ?? string.Empty, GuestPhone = booking.GuestPhone, FacilityName = booking.Facility?.Name ?? string.Empty, BookingStartTime = booking.StartTime, BookingEndTime = booking.EndTime }); } 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 is "Cancelled" or "NoShow" or "Completed") throw new ArgumentException($"This booking is already {booking.Status} and can't be 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, includeOtp: false); } catch { await _uow.RollbackAsync(); throw; } } public async Task ReassignBooking(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.IsGuest) throw new ArgumentException("Only guest bookings can be reassigned"); if (booking.Status != "Confirmed") throw new ArgumentException("Only a booking that hasn't been checked in yet can be reassigned"); if (data.ContainsKey("guestName")) booking.GuestName = data["guestName"].GetString() ?? booking.GuestName; if (data.ContainsKey("guestPhone")) booking.GuestPhone = data["guestPhone"].GetString() ?? booking.GuestPhone; await _uow.CommitAsync(); return MapToDto(booking, includeOtp: false); } catch { await _uow.RollbackAsync(); throw; } } public async Task RescheduleBooking(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 != "Confirmed") throw new ArgumentException("Only a booking that hasn't been checked in yet can be rescheduled"); if (booking.FKFacilitySessionId == null) { // AdHoc: move to a new time on the same facility. if (!data.ContainsKey("startTime") || !data.ContainsKey("durationMinutes")) throw new ArgumentException("startTime and durationMinutes are required to reschedule an ad-hoc booking"); var startTime = DateTime.Parse(data["startTime"].GetString()!).ToUniversalTime(); var durationMinutes = data["durationMinutes"].GetInt32(); if (durationMinutes < 1) throw new ArgumentException("durationMinutes must be at least 1"); var endTime = startTime.AddMinutes(durationMinutes); await _facilityRepository.LockFacilityForUpdateAsync(booking.FKFacilityId); var overlapping = await _facilityRepository.GetOverlappingAdhocBookingCountAsync( booking.FKFacilityId, startTime, endTime, excludeBookingId: booking.BookingId); var facility = booking.Facility ?? await _facilityRepository.GetFacilityById(booking.FKFacilityId) ?? throw new KeyNotFoundException("Facility not found"); if (overlapping >= facility.NumberOfUnits) throw new ArgumentException("No units are free for the requested time"); if (booking.PaymentStatus != "Paid" && booking.IsGuest) { var durationHours = (decimal)(endTime - startTime).TotalHours; booking.AmountCharged = Math.Round(facility.PricePerHour * durationHours, 2); } booking.StartTime = startTime; booking.EndTime = endTime; } else { // Scheduled: move to a different pre-created session on the same facility. if (!data.ContainsKey("facilitySessionId")) throw new ArgumentException("facilitySessionId is required to reschedule a scheduled booking"); var newSessionId = Guid.Parse(data["facilitySessionId"].GetString()!); var newSession = await _facilityRepository.GetFacilitySessionById(newSessionId) ?? throw new KeyNotFoundException("Facility session not found"); if (newSession.FKFacilityId != booking.FKFacilityId) throw new ArgumentException("The new session must be on the same facility"); if (newSession.IsCancelled) throw new ArgumentException("This session has been cancelled"); if (newSession.StartTime <= DateTime.UtcNow) throw new ArgumentException("This session has already started"); var capacity = newSession.Capacity ?? newSession.Facility?.DefaultCapacity ?? 1; var bookedCount = await _facilityRepository.GetActiveBookingCount(newSessionId); // Excluding this booking's own current session membership naturally falls out // here since bookedCount only counts rows already on newSessionId, which this // booking isn't yet. if (bookedCount >= capacity) throw new ArgumentException("This session is fully booked"); booking.FKFacilitySessionId = newSessionId; booking.StartTime = newSession.StartTime; booking.EndTime = newSession.EndTime; } await _uow.CommitAsync(); return MapToDto(booking, includeOtp: false); } catch { await _uow.RollbackAsync(); throw; } } public async Task MarkNoShow(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 != "Confirmed") throw new ArgumentException("Only a Confirmed booking can be marked no-show"); booking.Status = "NoShow"; await _uow.CommitAsync(); return MapToDto(booking, includeOtp: false); } catch { await _uow.RollbackAsync(); throw; } } public async Task CompleteBooking(object payload, HttpContext httpContext) { await _uow.BeginAsync(); try { var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); await SweepOverdueBookingsAsync(clientId, DateTime.UtcNow); 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 != "CheckedIn") throw new ArgumentException("Only a CheckedIn booking can be completed"); // A guest booking can't be checked out while still Unpaid -- payment must be // confirmed in this same call. Member bookings (never Unpaid) and guest bookings // already Paid skip straight through. if (booking.IsGuest && booking.PaymentStatus == "Unpaid") { var markPaid = data.ContainsKey("markPaid") && data["markPaid"].ValueKind != JsonValueKind.Null && data["markPaid"].GetBoolean(); if (!markPaid) throw new ArgumentException("This booking must be paid before it can be checked out"); var amountCollected = data.ContainsKey("amountCollected") && data["amountCollected"].ValueKind != JsonValueKind.Null ? data["amountCollected"].GetDecimal() : (decimal?)null; var paymentMethod = data.ContainsKey("paymentMethod") && data["paymentMethod"].ValueKind != JsonValueKind.Null ? data["paymentMethod"].GetString() ?? "Cash" : "Cash"; booking.PaymentStatus = "Paid"; booking.AmountCharged = amountCollected ?? booking.AmountCharged; booking.PaymentMethod = paymentMethod; booking.PaidAt = DateTime.UtcNow; await CreateBookingInvoiceAsync(booking, paymentMethod); } var now = DateTime.UtcNow; booking.Status = "Completed"; booking.CheckedOutAt = now; if (booking.RfidTag != null && booking.RfidReturnedAt == null) { booking.RfidTag.Status = "Available"; booking.RfidTag.UpdatedAt = now; booking.RfidReturnedAt = now; } await _uow.CommitAsync(); return MapToDto(booking, includeOtp: false); } catch { await _uow.RollbackAsync(); throw; } } // ---------- Reads ---------- 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, 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, includeOtp: false)).ToList(); } public async Task GetBookings(object payload, HttpContext httpContext) { var data = DeserializePayload.Deserialize(payload); Guid? facilityId = data.ContainsKey("facilityId") && data["facilityId"].ValueKind != JsonValueKind.Null ? Guid.Parse(data["facilityId"].GetString()!) : null; Guid? facilitySessionId = data.ContainsKey("facilitySessionId") && data["facilitySessionId"].ValueKind != JsonValueKind.Null ? Guid.Parse(data["facilitySessionId"].GetString()!) : null; DateTime? fromUtc = data.ContainsKey("fromDate") && data["fromDate"].ValueKind != JsonValueKind.Null ? DateTime.Parse(data["fromDate"].GetString()!).ToUniversalTime() : null; DateTime? toUtc = data.ContainsKey("toDate") && data["toDate"].ValueKind != JsonValueKind.Null ? DateTime.Parse(data["toDate"].GetString()!).ToUniversalTime() : null; string? status = data.ContainsKey("status") && data["status"].ValueKind != JsonValueKind.Null ? data["status"].GetString() : null; var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); var bookings = await _bookingRepository.GetBookings(clientId, facilityId, facilitySessionId, fromUtc, toUtc, status); return bookings.Select(b => MapToDto(b, 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, includeOtp: !booking.OtpVerifiedAt.HasValue); } public async Task GetBookingInvoices(object payload, HttpContext httpContext) { var data = DeserializePayload.Deserialize(payload); DateTime? fromUtc = data.ContainsKey("fromDate") && data["fromDate"].ValueKind != JsonValueKind.Null ? DateTime.Parse(data["fromDate"].GetString()!).ToUniversalTime() : null; DateTime? toUtc = data.ContainsKey("toDate") && data["toDate"].ValueKind != JsonValueKind.Null ? DateTime.Parse(data["toDate"].GetString()!).ToUniversalTime() : null; var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); var invoices = await _bookingRepository.GetBookingInvoices(clientId, fromUtc, toUtc); return invoices.Select(i => MapInvoiceToDto(i, BuildInvoiceShareUrl(httpContext, i.BookingInvoiceId))).ToList(); } public async Task GetBookingInvoiceView(object payload) { var data = DeserializePayload.Deserialize(payload); var bookingInvoiceId = Guid.Parse(data["bookingInvoiceId"].GetString()!); var invoice = await _bookingRepository.GetBookingInvoiceById(bookingInvoiceId); if (invoice == null) return null; return MapInvoiceToDto(invoice, shareUrl: null); } public async Task GetBookingDashboardSummary(object payload, HttpContext httpContext) { var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext)); var (bookingsToday, revenueToday, checkedInNow, upcomingConfirmed, outstandingUnpaid) = await _bookingRepository.GetDashboardSummaryAsync(clientId, DateTime.UtcNow); return new BookingDashboardSummaryDto { BookingsToday = bookingsToday, RevenueToday = revenueToday, CheckedInNow = checkedInNow, UpcomingConfirmed = upcomingConfirmed, OutstandingUnpaid = outstandingUnpaid }; } 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 string BuildInvoiceShareUrl(HttpContext httpContext, Guid bookingInvoiceId) => $"{httpContext.Request.Scheme}://{httpContext.Request.Host}/api/public/booking-invoices/{bookingInvoiceId}/view"; private static BookingDto MapToDto(Booking booking, bool includeOtp, string? shareUrl = null) => new() { BookingId = booking.BookingId, FacilitySessionId = booking.FKFacilitySessionId, FacilityId = booking.FKFacilityId, FacilityName = booking.Facility?.Name, BookingMode = booking.Facility?.BookingMode ?? (booking.FKFacilitySessionId.HasValue ? "Scheduled" : "AdHoc"), StartTime = booking.StartTime, EndTime = booking.EndTime, PartySize = booking.PartySize, IsGuest = booking.IsGuest, CustomerId = booking.FKCustomerId, GuestName = booking.GuestName, GuestPhone = booking.GuestPhone, OtpCode = includeOtp ? booking.OtpCode : null, OtpVerified = booking.OtpVerifiedAt.HasValue, RfidTagId = booking.FKRfidTagId, RfidTagLabel = booking.RfidTag?.Label ?? booking.RfidTag?.TagCode, RfidAssignedAt = booking.RfidAssignedAt, RfidReturnedAt = booking.RfidReturnedAt, PaymentStatus = booking.PaymentStatus, AmountCharged = booking.AmountCharged, PaymentMethod = booking.PaymentMethod, PaidAt = booking.PaidAt, Status = booking.Status, BookedAt = booking.BookedAt, CheckedInAt = booking.CheckedInAt, CheckedOutAt = booking.CheckedOutAt, ShareUrl = shareUrl }; private static BookingInvoiceDto MapInvoiceToDto(BookingInvoice invoice, string? shareUrl) => new() { BookingInvoiceId = invoice.BookingInvoiceId, BookingId = invoice.FKBookingId, Amount = invoice.Amount, PaymentMethod = invoice.PaymentMethod, GuestName = invoice.GuestName, GuestPhone = invoice.GuestPhone, FacilityName = invoice.FacilityName, BookingStartTime = invoice.BookingStartTime, BookingEndTime = invoice.BookingEndTime, IssuedAt = invoice.IssuedAt, ShareUrl = shareUrl }; } }