diff --git a/Controllers/BookingController.cs b/Controllers/BookingController.cs index d12318f..4b07266 100644 --- a/Controllers/BookingController.cs +++ b/Controllers/BookingController.cs @@ -65,7 +65,8 @@ namespace Fitness_core_BE.Controllers { facilitySessionId = request.FacilitySessionId, guestName = request.GuestName, - guestPhone = request.GuestPhone + guestPhone = request.GuestPhone, + customerId = request.CustomerId }, HttpContext)); [AllowAnonymous] @@ -79,7 +80,8 @@ namespace Fitness_core_BE.Controllers guestName = request.GuestName, guestPhone = request.GuestPhone, partySize = request.PartySize, - pricePerHour = request.PricePerHour + pricePerHour = request.PricePerHour, + customerId = request.CustomerId }, HttpContext)); // The actual "copy the link and share it" page -- BookingDto.ShareUrl points here. @@ -135,7 +137,12 @@ namespace Fitness_core_BE.Controllers [RequiresPermission("bookings")] [HttpPost("api/client/bookings/{id:guid}/mark-paid")] public async Task>> MarkGuestBookingPaid(Guid id, [FromBody] MarkGuestBookingPaidRequest request) - => Response(await _bookingService.MarkGuestBookingPaid(new { bookingId = id, amountCharged = request.AmountCharged }, HttpContext)); + => Response(await _bookingService.MarkGuestBookingPaid(new + { + bookingId = id, + amountCharged = request.AmountCharged, + paymentMethod = request.PaymentMethod + }, HttpContext)); [CustomMiddleware] [TenantResolution] @@ -180,7 +187,44 @@ namespace Fitness_core_BE.Controllers [TenantResolution] [RequiresPermission("bookings")] [HttpPost("api/client/bookings/{id:guid}/complete")] - public async Task>> CompleteBooking(Guid id) - => Response(await _bookingService.CompleteBooking(new { bookingId = id }, HttpContext)); + public async Task>> CompleteBooking(Guid id, [FromBody] CompleteBookingRequest? request) + => Response(await _bookingService.CompleteBooking(new + { + bookingId = id, + markPaid = request?.MarkPaid, + amountCollected = request?.AmountCollected, + paymentMethod = request?.PaymentMethod + }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("bookings")] + [HttpGet("api/client/bookings/invoices")] + public async Task>> GetBookingInvoices([FromQuery] DateTime? fromDate, [FromQuery] DateTime? toDate) + => Response(await _bookingService.GetBookingInvoices(new + { + fromDate = fromDate?.ToString("o"), + toDate = toDate?.ToString("o") + }, HttpContext)); + + [CustomMiddleware] + [TenantResolution] + [RequiresPermission("bookings")] + [HttpGet("api/client/bookings/dashboard-summary")] + public async Task>> GetBookingDashboardSummary() + => Response(await _bookingService.GetBookingDashboardSummary(new { }, HttpContext)); + + // Public, unguessable-GUID receipt page -- same "public but the link IS the credential" + // model as ViewBooking above. + [AllowAnonymous] + [HttpGet("api/public/booking-invoices/{id:guid}/view")] + public async Task ViewBookingInvoice(Guid id) + { + var invoice = await _bookingService.GetBookingInvoiceView(new { bookingInvoiceId = id }) as BookingInvoiceDto; + if (invoice == null) + return Content(BookingInvoiceHtml.NotFound(), "text/html"); + + return Content(BookingInvoiceHtml.Render(invoice), "text/html"); + } } } diff --git a/DTOs/Booking/BookingDashboardSummaryDto.cs b/DTOs/Booking/BookingDashboardSummaryDto.cs new file mode 100644 index 0000000..824dc49 --- /dev/null +++ b/DTOs/Booking/BookingDashboardSummaryDto.cs @@ -0,0 +1,12 @@ +namespace Fitness_core_BE.DTOs.Booking +{ + /// Backs the Bookings page's Dashboard tab stat-card row. + public class BookingDashboardSummaryDto + { + public int BookingsToday { get; set; } + public decimal RevenueToday { get; set; } + public int CheckedInNow { get; set; } + public int UpcomingConfirmed { get; set; } + public decimal OutstandingUnpaid { get; set; } + } +} diff --git a/DTOs/Booking/BookingDto.cs b/DTOs/Booking/BookingDto.cs index 9be538e..1d8e7da 100644 --- a/DTOs/Booking/BookingDto.cs +++ b/DTOs/Booking/BookingDto.cs @@ -37,6 +37,9 @@ namespace Fitness_core_BE.DTOs.Booking public string PaymentStatus { get; set; } = string.Empty; public decimal AmountCharged { get; set; } + /// "Cash" | "Bank". Null until PaymentStatus becomes "Paid". + public string? PaymentMethod { get; set; } + public DateTime? PaidAt { get; set; } public string Status { get; set; } = string.Empty; public DateTime BookedAt { get; set; } public DateTime? CheckedInAt { get; set; } diff --git a/DTOs/Booking/BookingInvoiceDto.cs b/DTOs/Booking/BookingInvoiceDto.cs new file mode 100644 index 0000000..3fcfd69 --- /dev/null +++ b/DTOs/Booking/BookingInvoiceDto.cs @@ -0,0 +1,21 @@ +namespace Fitness_core_BE.DTOs.Booking +{ + /// A booking payment receipt -- deliberately separate from the membership-based + /// CustomerInvoice, listed on its own "Invoices" tab, not mixed into Customer Payments. + public class BookingInvoiceDto + { + public Guid BookingInvoiceId { get; set; } + public Guid BookingId { get; set; } + public decimal Amount { get; set; } + public string PaymentMethod { get; set; } = string.Empty; + public string GuestName { get; set; } = string.Empty; + public string? GuestPhone { get; set; } + public string FacilityName { get; set; } = string.Empty; + public DateTime BookingStartTime { get; set; } + public DateTime BookingEndTime { get; set; } + public DateTime IssuedAt { get; set; } + + /// Public, unauthenticated link to this receipt's printable page. + public string? ShareUrl { get; set; } + } +} diff --git a/DTOs/Booking/BookingRequests.cs b/DTOs/Booking/BookingRequests.cs index d81c174..c52a523 100644 --- a/DTOs/Booking/BookingRequests.cs +++ b/DTOs/Booking/BookingRequests.cs @@ -18,6 +18,11 @@ namespace Fitness_core_BE.DTOs.Booking [Required] public string GuestPhone { get; set; } = string.Empty; + + /// Optional link to an existing registered customer (TenantCustomerLinkId) when + /// staff recognizes the walk-in as an already-registered member -- purely for + /// identification/auto-fill, doesn't change payment/membership handling at all. + public Guid? CustomerId { get; set; } } public class CreateAdhocBookingRequest @@ -57,6 +62,10 @@ namespace Fitness_core_BE.DTOs.Booking /// the FE pre-fills this from the facility but lets staff adjust it. [Range(typeof(decimal), "0", "79228162514264337593543950335", ErrorMessage = "PricePerHour cannot be negative")] public decimal? PricePerHour { get; set; } + + /// Optional link to an existing registered customer -- see + /// CreateGuestBookingRequest.CustomerId. + public Guid? CustomerId { get; set; } } public class MarkGuestBookingPaidRequest @@ -67,6 +76,20 @@ namespace Fitness_core_BE.DTOs.Booking [Required] [Range(0.01, double.MaxValue, ErrorMessage = "AmountCharged must be greater than zero")] public decimal AmountCharged { get; set; } + + /// "Cash" | "Bank". Defaults to "Cash" if omitted. + public string? PaymentMethod { get; set; } + } + + /// Body for POST .../complete. Only required when the booking is a guest booking + /// still Unpaid -- see BookingService.CompleteBooking. Ignored otherwise (member bookings, + /// or a guest booking already Paid). + public class CompleteBookingRequest + { + public bool? MarkPaid { get; set; } + public decimal? AmountCollected { get; set; } + /// "Cash" | "Bank". Defaults to "Cash" if omitted. + public string? PaymentMethod { get; set; } } public class CheckInBookingRequest diff --git a/Helpers/BookingInvoiceHtml.cs b/Helpers/BookingInvoiceHtml.cs new file mode 100644 index 0000000..eb4f57a --- /dev/null +++ b/Helpers/BookingInvoiceHtml.cs @@ -0,0 +1,68 @@ +using Fitness_core_BE.DTOs.Booking; +using System.Net; + +namespace Fitness_core_BE.Helpers +{ + // Minimal, dependency-free HTML receipt for a booking payment -- mirrors BookingViewHtml.cs's + // "self-contained page, share the link" pattern, kept as its own file since a receipt's + // content (itemized charge, payment method) is different enough from the booking confirmation + // page to not share a template. + public static class BookingInvoiceHtml + { + public static string Render(BookingInvoiceDto invoice) + { + var when = $"{invoice.BookingStartTime:dddd, dd MMM yyyy}, {invoice.BookingStartTime:h:mm tt} - {invoice.BookingEndTime:h:mm tt}"; + + return $$""" + + + + + + Payment Receipt - Vijaya Sport Club + + + +
+

Vijaya Sport Club — Receipt

+
+
Facility{{Encode(invoice.FacilityName)}}
+
Session{{Encode(when)}}
+
Paid by{{Encode(invoice.GuestName)}}
+
Payment Method{{Encode(invoice.PaymentMethod)}}
+
Issued{{invoice.IssuedAt:dd MMM yyyy, h:mm tt}}
+
+
Amount Paid
+
Rs. {{invoice.Amount:N2}}
+
+
+
+ + + """; + } + + public static string NotFound() => """ + + Receipt not found + +

Receipt not found

+

This link may be invalid.

+ + """; + + private static string Encode(string? value) => WebUtility.HtmlEncode(value ?? ""); + } +} diff --git a/Interfaces/IBookingRepository.cs b/Interfaces/IBookingRepository.cs index ec3e4ec..39d3a60 100644 --- a/Interfaces/IBookingRepository.cs +++ b/Interfaces/IBookingRepository.cs @@ -19,21 +19,31 @@ namespace Fitness_core_BE.Interfaces Task> GetBookings(Guid clientId, Guid? facilityId, Guid? facilitySessionId, DateTime? fromUtc, DateTime? toUtc, string? status); // --- Lazy overdue reconciliation (BookingService.SweepOverdueBookingsAsync), run at the - // top of every check-in instead of a background job -- see Booking.Status doc comment. --- + // top of every check-in AND every checkout instead of a background job -- see + // Booking.Status doc comment. --- /// Bulk-flips every Confirmed booking for this client whose EndTime has passed /// to NoShow. Returns the number of rows affected (informational/logging only). Task MarkOverdueConfirmedAsNoShowAsync(Guid clientId, DateTime now); - /// Every CheckedIn booking for this client whose EndTime has passed and hasn't - /// been checked out yet, with its RfidTag eagerly loaded so the caller can complete + - /// release the tag on the same tracked graph in one SaveChanges. - Task> GetOverdueCheckedInBookingsAsync(Guid clientId, DateTime now); - /// Atomically flips a tag from Available to InUse -- the ONE place a real race /// matters (two staff picking the same tag for two different bookings must not both /// win), so this uses a conditional bulk update rather than read-then-write. Returns /// false if the tag was no longer Available. Task TryClaimRfidTagAsync(Guid tagId, DateTime now); + + // --- Booking invoices (snapshot receipts created when a payment is recorded) --- + + Task CreateBookingInvoice(BookingInvoice invoice); + + /// Listed on their own "Invoices" tab -- deliberately not mixed into the + /// membership-based Customer Payments view. + Task> GetBookingInvoices(Guid clientId, DateTime? fromUtc, DateTime? toUtc); + + Task GetBookingInvoiceById(Guid bookingInvoiceId); + + /// Aggregate counts/sums backing the Bookings page's Dashboard tab. + Task<(int bookingsToday, decimal revenueToday, int checkedInNow, int upcomingConfirmed, decimal outstandingUnpaid)> + GetDashboardSummaryAsync(Guid clientId, DateTime now); } } diff --git a/Interfaces/ICustomerRepository.cs b/Interfaces/ICustomerRepository.cs index 8f94b63..6dbc3c2 100644 --- a/Interfaces/ICustomerRepository.cs +++ b/Interfaces/ICustomerRepository.cs @@ -31,6 +31,9 @@ namespace Fitness_core_BE.Interfaces // gym's own schema) -- no clientId parameter needed. Task FindActiveLinkByIdentifierAsync(string identifier); + // Staff picking an existing member to link a walk-in booking to -- identification only. + Task GetTenantCustomerLinkById(Guid id); + Task DeleteCustomer(Guid customerId); // Client-initiated delete: deactivates only this customer's presence at the calling // client's gym (subscriptions/members/plan assignments scoped to clientId). The shared diff --git a/Interfaces/Services/IBookingService.cs b/Interfaces/Services/IBookingService.cs index 40b84f1..6d458c8 100644 --- a/Interfaces/Services/IBookingService.cs +++ b/Interfaces/Services/IBookingService.cs @@ -43,8 +43,10 @@ namespace Fitness_core_BE.Interfaces.Services /// Staff manually flags a Confirmed, never-arrived booking. Task MarkNoShow(object payload, HttpContext httpContext); - /// Staff manually ends a CheckedIn booking early (before the lazy sweep would - /// catch it), releasing its RFID tag if any. + /// Staff checks out a CheckedIn booking, releasing its RFID tag if any. A guest + /// booking still Unpaid must confirm payment in this same call -- see + /// BookingService.CompleteBooking. Also lazily sweeps this client's overdue Confirmed + /// bookings to NoShow first, same as CheckInBooking. Task CompleteBooking(object payload, HttpContext httpContext); Task GetMyBookings(object payload, HttpContext httpContext); @@ -52,5 +54,16 @@ namespace Fitness_core_BE.Interfaces.Services /// Unified roster covering both booking modes. Task GetBookings(object payload, HttpContext httpContext); + + /// Receipts generated whenever a booking's payment is recorded -- their own + /// "Invoices" tab, separate from the membership-based Customer Payments view. + Task GetBookingInvoices(object payload, HttpContext httpContext); + + /// Renders the public, unauthenticated receipt page a BookingInvoice's ShareUrl + /// points to. Returns null if not found. + Task GetBookingInvoiceView(object payload); + + /// Stat-card summary for the Bookings page's Dashboard tab. + Task GetBookingDashboardSummary(object payload, HttpContext httpContext); } } diff --git a/Models/Booking.cs b/Models/Booking.cs index fa174a0..ddb2a39 100644 --- a/Models/Booking.cs +++ b/Models/Booking.cs @@ -86,24 +86,34 @@ namespace Fitness_core_BE.Models public string PaymentStatus { get; set; } = "NotRequired"; public decimal AmountCharged { get; set; } = 0m; + /// "Cash" | "Bank" -- set alongside PaymentStatus becoming "Paid" (MarkGuestBookingPaid + /// or the Complete checkout flow). Null until then. + [MaxLength(20)] + public string? PaymentMethod { get; set; } + /// Set the moment PaymentStatus becomes "Paid" -- distinct from BookedAt/CheckedOutAt, + /// this is specifically when the money was recorded, used to bucket booking revenue into + /// Profit & Loss and to date-stamp the BookingInvoice snapshot. + public DateTime? PaidAt { get; set; } + // Set only once payment is confirmed for a pay-per-session guest booking, or // immediately for a member session (no payment gate) -- see PaymentStatus. public Guid? FKPaymentTransactionId { get; set; } [ForeignKey(nameof(FKPaymentTransactionId))] public PaymentTransaction? PaymentTransaction { get; set; } - /// "Confirmed" | "CheckedIn" | "Cancelled" | "NoShow" | "Completed". NoShow and - /// Completed are reached either by explicit staff action (MarkNoShow/Complete) or lazily, - /// swept by BookingService.SweepOverdueBookingsAsync at the top of every check-in call - /// (not a background job -- overdue bookings are reconciled the next time any check-in - /// happens for this client, across every facility). + /// "Confirmed" | "CheckedIn" | "Cancelled" | "NoShow" | "Completed". NoShow is + /// reached either by explicit staff action (MarkNoShow) or lazily, swept by + /// BookingService.SweepOverdueBookingsAsync at the top of every check-in or checkout call + /// (not a background job -- overdue Confirmed bookings are reconciled the next time any + /// check-in/checkout happens for this client, across every facility). Completed is + /// reached ONLY by the explicit Complete action -- a guest booking can't reach it while + /// still Unpaid, so there's no automatic path that would check someone out unpaid. public string Status { get; set; } = "Confirmed"; public DateTime BookedAt { get; set; } = DateTime.UtcNow; public DateTime? CheckedInAt { get; set; } - /// Set when the booking reaches Completed, whether via the manual Complete - /// action or the lazy overdue-sweep -- the actual moment it was reconciled, not - /// necessarily EndTime. + /// Set when the booking reaches Completed via the manual Complete action -- the + /// actual moment it was checked out, not necessarily EndTime. public DateTime? CheckedOutAt { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; } diff --git a/Models/BookingInvoice.cs b/Models/BookingInvoice.cs new file mode 100644 index 0000000..efb569e --- /dev/null +++ b/Models/BookingInvoice.cs @@ -0,0 +1,44 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Fitness_core_BE.Models +{ + /// A snapshot receipt generated the moment a Booking's payment is recorded (either + /// via MarkGuestBookingPaid or the Complete checkout flow) -- deliberately NOT a live join to + /// Booking/Facility, so it stays stable and printable even if the booking is later reassigned, + /// rescheduled, or the facility renamed. Purpose-built for bookings and kept entirely separate + /// from CustomerInvoice, which is hardwired to the membership/subscription domain + /// (FKCustomerSubscriptionId is required there) and has no concept of a one-off booking + /// charge. + public class BookingInvoice + { + [Key] + public Guid BookingInvoiceId { get; set; } + + public Guid FKBookingId { get; set; } + [ForeignKey(nameof(FKBookingId))] + public Booking? Booking { get; set; } + + public Guid FKClientId { get; set; } + [ForeignKey(nameof(FKClientId))] + public Client Client { get; set; } + + public decimal Amount { get; set; } + + /// "Cash" | "Bank". + [MaxLength(20)] + public string PaymentMethod { get; set; } = "Cash"; + + [MaxLength(200)] + public string GuestName { get; set; } = string.Empty; + [MaxLength(32)] + public string? GuestPhone { get; set; } + + [MaxLength(128)] + public string FacilityName { get; set; } = string.Empty; + public DateTime BookingStartTime { get; set; } + public DateTime BookingEndTime { get; set; } + + public DateTime IssuedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/Repos/BookingRepository.cs b/Repos/BookingRepository.cs index 146a3ce..4fa4e30 100644 --- a/Repos/BookingRepository.cs +++ b/Repos/BookingRepository.cs @@ -81,12 +81,6 @@ namespace Fitness_core_BE.Repos .Where(b => b.FKClientId == clientId && b.Status == "Confirmed" && b.EndTime < now) .ExecuteUpdateAsync(s => s.SetProperty(b => b.Status, "NoShow")); - public Task> GetOverdueCheckedInBookingsAsync(Guid clientId, DateTime now) - => _context.Bookings - .Include(b => b.RfidTag) - .Where(b => b.FKClientId == clientId && b.Status == "CheckedIn" && b.EndTime < now) - .ToListAsync(); - public async Task TryClaimRfidTagAsync(Guid tagId, DateTime now) { var rows = await _context.RfidTags @@ -96,5 +90,52 @@ namespace Fitness_core_BE.Repos .SetProperty(t => t.UpdatedAt, now)); return rows > 0; } + + public async Task CreateBookingInvoice(BookingInvoice invoice) + { + invoice.BookingInvoiceId = Guid.NewGuid(); + invoice.IssuedAt = DateTime.UtcNow; + + _context.BookingInvoices.Add(invoice); + return invoice; + } + + public Task> GetBookingInvoices(Guid clientId, DateTime? fromUtc, DateTime? toUtc) + { + var query = _context.BookingInvoices.Where(i => i.FKClientId == clientId); + if (fromUtc.HasValue) query = query.Where(i => i.IssuedAt >= fromUtc.Value); + if (toUtc.HasValue) query = query.Where(i => i.IssuedAt <= toUtc.Value); + + return query.OrderByDescending(i => i.IssuedAt).ToListAsync(); + } + + public Task GetBookingInvoiceById(Guid bookingInvoiceId) + => _context.BookingInvoices.FirstOrDefaultAsync(i => i.BookingInvoiceId == bookingInvoiceId); + + public async Task<(int bookingsToday, decimal revenueToday, int checkedInNow, int upcomingConfirmed, decimal outstandingUnpaid)> + GetDashboardSummaryAsync(Guid clientId, DateTime now) + { + var todayStart = now.Date; + var todayEnd = todayStart.AddDays(1); + + var bookingsToday = await _context.Bookings + .CountAsync(b => b.FKClientId == clientId && b.StartTime >= todayStart && b.StartTime < todayEnd); + + var revenueToday = await _context.Bookings + .Where(b => b.FKClientId == clientId && b.PaidAt >= todayStart && b.PaidAt < todayEnd) + .SumAsync(b => (decimal?)b.AmountCharged) ?? 0m; + + var checkedInNow = await _context.Bookings + .CountAsync(b => b.FKClientId == clientId && b.Status == "CheckedIn"); + + var upcomingConfirmed = await _context.Bookings + .CountAsync(b => b.FKClientId == clientId && b.Status == "Confirmed" && b.StartTime > now); + + var outstandingUnpaid = await _context.Bookings + .Where(b => b.FKClientId == clientId && b.PaymentStatus == "Unpaid") + .SumAsync(b => (decimal?)b.AmountCharged) ?? 0m; + + return (bookingsToday, revenueToday, checkedInNow, upcomingConfirmed, outstandingUnpaid); + } } } diff --git a/Repos/CustomerRepository.cs b/Repos/CustomerRepository.cs index 0b0cf5a..3f13ac6 100644 --- a/Repos/CustomerRepository.cs +++ b/Repos/CustomerRepository.cs @@ -115,6 +115,13 @@ namespace Fitness_core_BE.Repos (l.PrimaryPhoneCached == id || l.EmailCached == id)); } + // Staff picking an existing member to link a walk-in booking to (identification only, + // see BookingService.CreateGuestBooking/CreateGuestAdhocBooking) -- tenant-scoped by + // construction, same as FindActiveLinkByIdentifierAsync above. + public Task GetTenantCustomerLinkById(Guid id) + => _context.TenantCustomerLink + .FirstOrDefaultAsync(l => l.TenantCustomerLinkId == id && l.IsActiveAtThisClient); + public async Task UpdateCustomer(Guid customerId, Customer updatedCustomer) { diff --git a/Repos/ProfitLossRepository.cs b/Repos/ProfitLossRepository.cs index 0bdc0ff..032b6d1 100644 --- a/Repos/ProfitLossRepository.cs +++ b/Repos/ProfitLossRepository.cs @@ -82,8 +82,32 @@ namespace Fitness_core_BE.Repos }) .ToListAsync(ct); + // Paid facility bookings (guest/walk-in) -- a plain Booking read, not a + // PaymentTransaction, since Booking has no invoice at all (see BookingInvoice, kept + // deliberately separate from CustomerInvoice/PaymentTransaction's membership-only + // wiring). PaidAt is the recognition date, not BookedAt/CheckedOutAt. + var bookingPayments = await _context.Bookings + .AsNoTracking() + .Where(booking => + booking.FKClientId == clientId && + booking.PaymentStatus == "Paid" && + booking.PaidAt != null && + booking.PaidAt >= fromUtc && + booking.PaidAt < toUtc) + .Select(booking => new LedgerEntryDto + { + EntryType = "Income", + Category = "Facility Bookings", + Description = booking.GuestName, + Amount = booking.AmountCharged, + EntryDate = booking.PaidAt!.Value, + IsManual = false + }) + .ToListAsync(ct); + memberPayments.AddRange(platformInvoices); memberPayments.AddRange(staffPayments); + memberPayments.AddRange(bookingPayments); return memberPayments; } diff --git a/Services/BookingService.cs b/Services/BookingService.cs index 3e0acc9..0ea8561 100644 --- a/Services/BookingService.cs +++ b/Services/BookingService.cs @@ -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); } + /// 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) @@ -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 } } - /// 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. + /// 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. + } - var overdueCheckedIn = await _bookingRepository.GetOverdueCheckedInBookingsAsync(clientId, now); - foreach (var booking in overdueCheckedIn) + /// 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 { - 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 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 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 @@ -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 + }; } } diff --git a/TenantDbContext.cs b/TenantDbContext.cs index de37bb8..8e37a6c 100644 --- a/TenantDbContext.cs +++ b/TenantDbContext.cs @@ -78,6 +78,10 @@ namespace Fitness_core_BE // TimeBankCard's own, unrelated RfidTagId string field. public DbSet RfidTags => Set(); + // Snapshot receipt created whenever a Booking's payment is recorded -- see + // Models/BookingInvoice.cs. Deliberately separate from CustomerInvoice (membership-only). + public DbSet BookingInvoices => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); @@ -184,6 +188,7 @@ namespace Fitness_core_BE modelBuilder.Entity().Ignore(f => f.Client); modelBuilder.Entity().Ignore(s => s.Client); modelBuilder.Entity().Ignore(b => b.Client); + modelBuilder.Entity().Ignore(i => i.Client); modelBuilder.Entity().Ignore(t => t.Client); modelBuilder.Entity().Ignore(l => l.Client); @@ -278,6 +283,9 @@ namespace Fitness_core_BE modelBuilder.Entity() .HasIndex(b => new { b.FKFacilityId, b.StartTime, b.EndTime }); + modelBuilder.Entity() + .HasIndex(i => new { i.FKClientId, i.IssuedAt }); + modelBuilder.Entity() .HasIndex(e => new { e.FKDeviceId, e.DeviceEmployeeNo, e.LinkedAt });