feat: Enhance booking functionality with invoice management and dashboard summary
- Added customerId to guest booking requests for better identification. - Implemented new endpoints for managing booking invoices and dashboard summaries. - Created BookingInvoice and BookingDashboardSummary DTOs to support new features. - Updated Booking and BookingInvoice models to include payment method and timestamps. - Enhanced BookingService to handle invoice creation upon payment confirmation. - Modified BookingRepository to support CRUD operations for BookingInvoice. - Updated TenantDbContext to include BookingInvoice DbSet and relevant configurations. - Improved error handling and validation for booking completion and payment processes.
This commit is contained in:
+143
-18
@@ -14,6 +14,7 @@ namespace Fitness_core_BE.Services
|
||||
IFacilityRepository facilityRepository,
|
||||
IRfidTagRepository rfidTagRepository,
|
||||
ICustomerSubscriptionRepository customerSubscriptionRepository,
|
||||
ICustomerRepository customerRepository,
|
||||
IUnitOfWork uow,
|
||||
UserRoleHelper userRoleHelper) : IBookingService
|
||||
{
|
||||
@@ -21,6 +22,7 @@ namespace Fitness_core_BE.Services
|
||||
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;
|
||||
|
||||
@@ -113,6 +115,7 @@ namespace Fitness_core_BE.Services
|
||||
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");
|
||||
@@ -135,6 +138,7 @@ namespace Fitness_core_BE.Services
|
||||
EndTime = session.EndTime,
|
||||
FKClientId = session.FKClientId,
|
||||
IsGuest = true,
|
||||
FKCustomerId = customerId,
|
||||
GuestName = guestName,
|
||||
GuestPhone = guestPhone,
|
||||
OtpCode = GenerateOtp(),
|
||||
@@ -207,6 +211,7 @@ namespace Fitness_core_BE.Services
|
||||
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;
|
||||
|
||||
@@ -220,6 +225,7 @@ namespace Fitness_core_BE.Services
|
||||
PartySize = partySize,
|
||||
FKClientId = facility.FKClientId,
|
||||
IsGuest = true,
|
||||
FKCustomerId = customerId,
|
||||
GuestName = guestName,
|
||||
GuestPhone = guestPhone,
|
||||
OtpCode = GenerateOtp(),
|
||||
@@ -283,6 +289,22 @@ namespace Fitness_core_BE.Services
|
||||
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/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.</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)
|
||||
@@ -293,6 +315,8 @@ namespace Fitness_core_BE.Services
|
||||
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");
|
||||
@@ -301,6 +325,9 @@ namespace Fitness_core_BE.Services
|
||||
|
||||
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);
|
||||
@@ -370,29 +397,40 @@ namespace Fitness_core_BE.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lazily reconciles everything a background job would otherwise have swept:
|
||||
/// Confirmed bookings whose window passed with no check-in become NoShow; CheckedIn
|
||||
/// bookings whose window passed become Completed and release their tag. Runs at the top
|
||||
/// of every check-in, across every facility for this client (not scoped to whichever
|
||||
/// facility is being checked into right now), so the whole roster stays accurate as long
|
||||
/// as check-ins keep happening anywhere in the club.</summary>
|
||||
/// <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.
|
||||
}
|
||||
|
||||
var overdueCheckedIn = await _bookingRepository.GetOverdueCheckedInBookingsAsync(clientId, now);
|
||||
foreach (var booking in overdueCheckedIn)
|
||||
/// <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
|
||||
{
|
||||
booking.Status = "Completed";
|
||||
booking.CheckedOutAt = now;
|
||||
if (booking.RfidTag != null && booking.RfidReturnedAt == null)
|
||||
{
|
||||
booking.RfidTag.Status = "Available";
|
||||
booking.RfidTag.UpdatedAt = now;
|
||||
booking.RfidReturnedAt = now;
|
||||
}
|
||||
}
|
||||
// Caller's _uow.CommitAsync() persists these tracked changes alongside its own.
|
||||
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)
|
||||
@@ -572,6 +610,9 @@ namespace Fitness_core_BE.Services
|
||||
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()!);
|
||||
|
||||
@@ -580,6 +621,28 @@ namespace Fitness_core_BE.Services
|
||||
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;
|
||||
@@ -655,6 +718,48 @@ namespace Fitness_core_BE.Services
|
||||
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
|
||||
@@ -663,6 +768,9 @@ namespace Fitness_core_BE.Services
|
||||
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,
|
||||
@@ -685,11 +793,28 @@ namespace Fitness_core_BE.Services
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user