first commit
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
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<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");
|
||||
|
||||
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<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 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<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 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<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");
|
||||
|
||||
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<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 == "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<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, b.FacilitySession, 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, b.FacilitySession, 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, 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
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user