5d711bd13e
Implements the facility-classes-booking-dashboard plan (backend): - Facility.BookingMode (single Scheduled/AdHoc choice) replaced with independent SupportsClasses/SupportsBooking flags, so one facility can run a class and accept walk-in bookings; a running class now blocks walk-in booking for its window on both create and reschedule. - CustomerPlan.GrantsGymAccess + CustomerPlanClass join table let a customer hold one gym-access plan plus any number of class-only plans. Replaced the old single-subscription resolution (GetActiveSubscriptionByCustomerId/HasAnyActiveSubscriptionAsync) with gym-access-aware and class-aware resolvers across booking creation/cancellation, plan assignment, and customer self-service. Fixed ChangeMembershipPlan/renewal plan-changes, which resolved "the subscription to replace" by most-recent-created and would have deactivated a member's class subscription instead of their gym one. - New FacilityClass model: recurring class definitions (Daily/Weekly/ Monthly/Custom) with lazy occurrence generation (8-week horizon, race-safe via a unique index) and proper unlimited-capacity handling. "Mark not held" replaces the old bare-cancel: cascades to cancel and refund every confirmed booking on the occurrence through the same core CancelBooking uses, and frees the slot for walk-in booking. Scheduling conflicts (a walk-in booking now overlapping a class) are surfaced for staff to resolve via the existing Reschedule flow. - Renamed the pre-existing "AdHoc" booking-mode terminology to "WalkIn" throughout (routes, methods, DTOs, the stored BookingMode value) for a clearer, simpler name. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
862 lines
43 KiB
C#
862 lines
43 KiB
C#
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<object> 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");
|
|
|
|
// A class occurrence (FKFacilityClassId set) must be covered by a subscription
|
|
// linked to that specific class; a legacy/manually-created session (null) falls
|
|
// back to the customer's gym-access subscription, same as before Classes existed.
|
|
var subscription = session.FKFacilityClassId.HasValue
|
|
? await _customerSubscriptionRepository.GetActiveSubscriptionForClassAsync(cusId, session.FKFacilityClassId.Value)
|
|
?? throw new ArgumentException("Your membership doesn't cover this class.")
|
|
: await _customerSubscriptionRepository.GetActiveGymAccessSubscriptionAsync(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");
|
|
}
|
|
|
|
if (!session.IsUnlimitedCapacity)
|
|
{
|
|
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<object> 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");
|
|
|
|
if (!session.IsUnlimitedCapacity)
|
|
{
|
|
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;
|
|
}
|
|
}
|
|
|
|
// ---------- WalkIn-mode creation (new) ----------
|
|
|
|
public async Task<object> CreateMemberWalkInBooking(object payload, HttpContext httpContext)
|
|
{
|
|
await _uow.BeginAsync();
|
|
try
|
|
{
|
|
var (facility, startTime, endTime, partySize, _) = await ValidateWalkInRequestAsync(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<object> CreateGuestWalkInBooking(object payload, HttpContext httpContext)
|
|
{
|
|
await _uow.BeginAsync();
|
|
try
|
|
{
|
|
var (facility, startTime, endTime, partySize, pricePerHour) = await ValidateWalkInRequestAsync(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;
|
|
}
|
|
}
|
|
|
|
/// <summary>Shared validation for both WalkIn creation paths: facility must support
|
|
/// walk-in booking, 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.</summary>
|
|
private async Task<(Facility facility, DateTime startTime, DateTime endTime, int? partySize, decimal? pricePerHour)> ValidateWalkInRequestAsync(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.SupportsBooking)
|
|
throw new ArgumentException("This facility does not support walk-in booking");
|
|
if (!facility.IsActive)
|
|
throw new ArgumentException("This facility is not active");
|
|
|
|
await _facilityRepository.LockFacilityForUpdateAsync(facilityId);
|
|
|
|
// A running class occupies the whole facility, not just a seat -- reject the walk-in
|
|
// request outright rather than letting it compete for units.
|
|
var overlappingSessions = await _facilityRepository.GetOverlappingActiveSessionsAsync(facilityId, startTime, endTime);
|
|
if (overlappingSessions.Count > 0)
|
|
throw new ArgumentException("This facility is occupied by a scheduled class during the requested time");
|
|
|
|
var overlapping = await _facilityRepository.GetOverlappingWalkInBookingCountAsync(facilityId, startTime, endTime);
|
|
if (overlapping >= facility.NumberOfUnits)
|
|
throw new ArgumentException("No units are free for the requested time");
|
|
|
|
return (facility, startTime, endTime, partySize, pricePerHour);
|
|
}
|
|
|
|
/// <summary>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/CreateGuestWalkInBooking. Doesn't change IsGuest, PaymentStatus, or
|
|
/// any membership handling; FKCustomerId is just an informational link layered on top of
|
|
/// an otherwise-ordinary guest booking.</summary>
|
|
private async Task<Guid?> ResolveOptionalCustomerLinkAsync(Dictionary<string, JsonElement> 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<object> 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<object> 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;
|
|
}
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
private async Task SweepOverdueBookingsAsync(Guid clientId, DateTime now)
|
|
{
|
|
await _bookingRepository.MarkOverdueConfirmedAsNoShowAsync(clientId, now);
|
|
// Caller's _uow.CommitAsync() persists this alongside its own changes.
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
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<object> 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");
|
|
|
|
await CancelBookingCore(booking);
|
|
|
|
await _uow.CommitAsync();
|
|
return MapToDto(booking, includeOtp: false);
|
|
}
|
|
catch
|
|
{
|
|
await _uow.RollbackAsync();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>Just the in-memory mutations (status + subscription allowance refund), no
|
|
/// transaction demarcation or status validation -- called both by CancelBooking (after
|
|
/// its own status checks) and by FacilityService's "mark not held" cascade (which does
|
|
/// its own per-booking filtering before calling this for each affected booking), so
|
|
/// there's one refund code path instead of two. Looks the subscription up by the exact
|
|
/// id the booking drew its allowance from, rather than re-resolving "the customer's
|
|
/// current active subscription" -- correct regardless of which kind of subscription
|
|
/// (gym-access or class-linked) it was, and unaffected by the customer holding others.</summary>
|
|
public async Task CancelBookingCore(Booking booking)
|
|
{
|
|
booking.Status = "Cancelled";
|
|
|
|
// Give the session allowance back -- only member bookings draw one down.
|
|
if (!booking.IsGuest && booking.FKCustomerSubscriptionId.HasValue)
|
|
{
|
|
var subscription = await _customerSubscriptionRepository.GetCustomerSubscriptionById(booking.FKCustomerSubscriptionId.Value);
|
|
if (subscription != null && subscription.CustomerPlan.SessionsPerCycle.HasValue && subscription.SessionsUsedThisCycle > 0)
|
|
{
|
|
subscription.SessionsUsedThisCycle--;
|
|
}
|
|
}
|
|
}
|
|
|
|
public async Task<object> 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<object> 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)
|
|
{
|
|
// WalkIn: 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 a walk-in 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);
|
|
|
|
// Same class-occupancy guard as a fresh walk-in request -- otherwise staff
|
|
// could reschedule straight into a scheduled class's time.
|
|
var overlappingSessions = await _facilityRepository.GetOverlappingActiveSessionsAsync(
|
|
booking.FKFacilityId, startTime, endTime);
|
|
if (overlappingSessions.Count > 0)
|
|
throw new ArgumentException("This facility is occupied by a scheduled class during the requested time");
|
|
|
|
var overlapping = await _facilityRepository.GetOverlappingWalkInBookingCountAsync(
|
|
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<object> 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<object> 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<object> 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<object> 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<object> 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<object?> 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<object> 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<object?> 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<object> 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,
|
|
// Per-booking fact (which path created it) -- a facility can support both modes now,
|
|
// so this no longer derives from the facility at all.
|
|
BookingMode = booking.FKFacilitySessionId.HasValue ? "Scheduled" : "WalkIn",
|
|
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
|
|
};
|
|
}
|
|
}
|