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:
2026-10-01 03:46:03 +05:30
parent 6abef835e8
commit 51f0021e27
16 changed files with 501 additions and 45 deletions
+49 -5
View File
@@ -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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> CompleteBooking(Guid id)
=> Response(await _bookingService.CompleteBooking(new { bookingId = id }, HttpContext));
public async Task<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ActionResult<ApiResponse<object>>> 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<ContentResult> 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");
}
}
}
@@ -0,0 +1,12 @@
namespace Fitness_core_BE.DTOs.Booking
{
/// <summary>Backs the Bookings page's Dashboard tab stat-card row.</summary>
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; }
}
}
+3
View File
@@ -37,6 +37,9 @@ namespace Fitness_core_BE.DTOs.Booking
public string PaymentStatus { get; set; } = string.Empty;
public decimal AmountCharged { get; set; }
/// <summary>"Cash" | "Bank". Null until PaymentStatus becomes "Paid".</summary>
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; }
+21
View File
@@ -0,0 +1,21 @@
namespace Fitness_core_BE.DTOs.Booking
{
/// <summary>A booking payment receipt -- deliberately separate from the membership-based
/// CustomerInvoice, listed on its own "Invoices" tab, not mixed into Customer Payments.</summary>
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; }
/// <summary>Public, unauthenticated link to this receipt's printable page.</summary>
public string? ShareUrl { get; set; }
}
}
+23
View File
@@ -18,6 +18,11 @@ namespace Fitness_core_BE.DTOs.Booking
[Required]
public string GuestPhone { get; set; } = string.Empty;
/// <summary>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.</summary>
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.</summary>
[Range(typeof(decimal), "0", "79228162514264337593543950335", ErrorMessage = "PricePerHour cannot be negative")]
public decimal? PricePerHour { get; set; }
/// <summary>Optional link to an existing registered customer -- see
/// CreateGuestBookingRequest.CustomerId.</summary>
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; }
/// <summary>"Cash" | "Bank". Defaults to "Cash" if omitted.</summary>
public string? PaymentMethod { get; set; }
}
/// <summary>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).</summary>
public class CompleteBookingRequest
{
public bool? MarkPaid { get; set; }
public decimal? AmountCollected { get; set; }
/// <summary>"Cash" | "Bank". Defaults to "Cash" if omitted.</summary>
public string? PaymentMethod { get; set; }
}
public class CheckInBookingRequest
+68
View File
@@ -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 $$"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Payment Receipt - Vijaya Sport Club</title>
<style>
body { font-family: -apple-system, Segoe UI, Roboto, Arial, sans-serif; background: #f3f4f6; margin: 0; padding: 24px 16px; color: #111827; }
.card { max-width: 420px; margin: 0 auto; background: #fff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); overflow: hidden; }
.header { background: #0f172a; color: #fff; padding: 20px; }
.header h1 { margin: 0; font-size: 18px; }
.body { padding: 20px; }
.row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #f1f5f9; }
.row .label { color: #6b7280; font-size: 13px; }
.row .value { font-weight: 600; font-size: 13px; text-align: right; }
.total { margin-top: 16px; text-align: center; background: #f8fafc; border: 1px dashed #cbd5e1; border-radius: 8px; padding: 16px; }
.total-label { font-size: 12px; color: #6b7280; margin-bottom: 6px; }
.total-amount { font-size: 28px; font-weight: 700; color: #0f172a; }
</style>
</head>
<body>
<div class="card">
<div class="header"><h1>Vijaya Sport Club — Receipt</h1></div>
<div class="body">
<div class="row"><span class="label">Facility</span><span class="value">{{Encode(invoice.FacilityName)}}</span></div>
<div class="row"><span class="label">Session</span><span class="value">{{Encode(when)}}</span></div>
<div class="row"><span class="label">Paid by</span><span class="value">{{Encode(invoice.GuestName)}}</span></div>
<div class="row"><span class="label">Payment Method</span><span class="value">{{Encode(invoice.PaymentMethod)}}</span></div>
<div class="row"><span class="label">Issued</span><span class="value">{{invoice.IssuedAt:dd MMM yyyy, h:mm tt}}</span></div>
<div class="total">
<div class="total-label">Amount Paid</div>
<div class="total-amount">Rs. {{invoice.Amount:N2}}</div>
</div>
</div>
</div>
</body>
</html>
""";
}
public static string NotFound() => """
<!DOCTYPE html>
<html><head><meta charset="utf-8" /><title>Receipt not found</title></head>
<body style="font-family: sans-serif; text-align: center; padding: 60px 20px; color: #6b7280;">
<h2>Receipt not found</h2>
<p>This link may be invalid.</p>
</body></html>
""";
private static string Encode(string? value) => WebUtility.HtmlEncode(value ?? "");
}
}
+16 -6
View File
@@ -19,21 +19,31 @@ namespace Fitness_core_BE.Interfaces
Task<List<Booking>> 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. ---
/// <summary>Bulk-flips every Confirmed booking for this client whose EndTime has passed
/// to NoShow. Returns the number of rows affected (informational/logging only).</summary>
Task<int> MarkOverdueConfirmedAsNoShowAsync(Guid clientId, DateTime now);
/// <summary>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.</summary>
Task<List<Booking>> GetOverdueCheckedInBookingsAsync(Guid clientId, DateTime now);
/// <summary>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.</summary>
Task<bool> TryClaimRfidTagAsync(Guid tagId, DateTime now);
// --- Booking invoices (snapshot receipts created when a payment is recorded) ---
Task<BookingInvoice> CreateBookingInvoice(BookingInvoice invoice);
/// <summary>Listed on their own "Invoices" tab -- deliberately not mixed into the
/// membership-based Customer Payments view.</summary>
Task<List<BookingInvoice>> GetBookingInvoices(Guid clientId, DateTime? fromUtc, DateTime? toUtc);
Task<BookingInvoice?> GetBookingInvoiceById(Guid bookingInvoiceId);
/// <summary>Aggregate counts/sums backing the Bookings page's Dashboard tab.</summary>
Task<(int bookingsToday, decimal revenueToday, int checkedInNow, int upcomingConfirmed, decimal outstandingUnpaid)>
GetDashboardSummaryAsync(Guid clientId, DateTime now);
}
}
+3
View File
@@ -31,6 +31,9 @@ namespace Fitness_core_BE.Interfaces
// gym's own schema) -- no clientId parameter needed.
Task<TenantCustomerLink?> FindActiveLinkByIdentifierAsync(string identifier);
// Staff picking an existing member to link a walk-in booking to -- identification only.
Task<TenantCustomerLink?> GetTenantCustomerLinkById(Guid id);
Task<bool> 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
+15 -2
View File
@@ -43,8 +43,10 @@ namespace Fitness_core_BE.Interfaces.Services
/// <summary>Staff manually flags a Confirmed, never-arrived booking.</summary>
Task<object> MarkNoShow(object payload, HttpContext httpContext);
/// <summary>Staff manually ends a CheckedIn booking early (before the lazy sweep would
/// catch it), releasing its RFID tag if any.</summary>
/// <summary>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.</summary>
Task<object> CompleteBooking(object payload, HttpContext httpContext);
Task<object> GetMyBookings(object payload, HttpContext httpContext);
@@ -52,5 +54,16 @@ namespace Fitness_core_BE.Interfaces.Services
/// <summary>Unified roster covering both booking modes.</summary>
Task<object> GetBookings(object payload, HttpContext httpContext);
/// <summary>Receipts generated whenever a booking's payment is recorded -- their own
/// "Invoices" tab, separate from the membership-based Customer Payments view.</summary>
Task<object> GetBookingInvoices(object payload, HttpContext httpContext);
/// <summary>Renders the public, unauthenticated receipt page a BookingInvoice's ShareUrl
/// points to. Returns null if not found.</summary>
Task<object?> GetBookingInvoiceView(object payload);
/// <summary>Stat-card summary for the Bookings page's Dashboard tab.</summary>
Task<object> GetBookingDashboardSummary(object payload, HttpContext httpContext);
}
}
+18 -8
View File
@@ -86,24 +86,34 @@ namespace Fitness_core_BE.Models
public string PaymentStatus { get; set; } = "NotRequired";
public decimal AmountCharged { get; set; } = 0m;
/// <summary>"Cash" | "Bank" -- set alongside PaymentStatus becoming "Paid" (MarkGuestBookingPaid
/// or the Complete checkout flow). Null until then.</summary>
[MaxLength(20)]
public string? PaymentMethod { get; set; }
/// <summary>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 &amp; Loss and to date-stamp the BookingInvoice snapshot.</summary>
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; }
/// <summary>"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).</summary>
/// <summary>"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.</summary>
public string Status { get; set; } = "Confirmed";
public DateTime BookedAt { get; set; } = DateTime.UtcNow;
public DateTime? CheckedInAt { get; set; }
/// <summary>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.</summary>
/// <summary>Set when the booking reaches Completed via the manual Complete action -- the
/// actual moment it was checked out, not necessarily EndTime.</summary>
public DateTime? CheckedOutAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
+44
View File
@@ -0,0 +1,44 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Fitness_core_BE.Models
{
/// <summary>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.</summary>
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; }
/// <summary>"Cash" | "Bank".</summary>
[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;
}
}
+47 -6
View File
@@ -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<List<Booking>> 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<bool> 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<BookingInvoice> CreateBookingInvoice(BookingInvoice invoice)
{
invoice.BookingInvoiceId = Guid.NewGuid();
invoice.IssuedAt = DateTime.UtcNow;
_context.BookingInvoices.Add(invoice);
return invoice;
}
public Task<List<BookingInvoice>> 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<BookingInvoice?> 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);
}
}
}
+7
View File
@@ -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<TenantCustomerLink?> GetTenantCustomerLinkById(Guid id)
=> _context.TenantCustomerLink
.FirstOrDefaultAsync(l => l.TenantCustomerLinkId == id && l.IsActiveAtThisClient);
public async Task<Customer?> UpdateCustomer(Guid customerId, Customer updatedCustomer)
{
+24
View File
@@ -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;
}
+142 -17
View File
@@ -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)
{
booking.Status = "Completed";
booking.CheckedOutAt = now;
if (booking.RfidTag != null && booking.RfidReturnedAt == null)
await _bookingRepository.CreateBookingInvoice(new BookingInvoice
{
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
};
}
}
+8
View File
@@ -78,6 +78,10 @@ namespace Fitness_core_BE
// TimeBankCard's own, unrelated RfidTagId string field.
public DbSet<RfidTag> RfidTags => Set<RfidTag>();
// Snapshot receipt created whenever a Booking's payment is recorded -- see
// Models/BookingInvoice.cs. Deliberately separate from CustomerInvoice (membership-only).
public DbSet<BookingInvoice> BookingInvoices => Set<BookingInvoice>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
@@ -184,6 +188,7 @@ namespace Fitness_core_BE
modelBuilder.Entity<Facility>().Ignore(f => f.Client);
modelBuilder.Entity<FacilitySession>().Ignore(s => s.Client);
modelBuilder.Entity<Booking>().Ignore(b => b.Client);
modelBuilder.Entity<BookingInvoice>().Ignore(i => i.Client);
modelBuilder.Entity<TimeBankCard>().Ignore(t => t.Client);
modelBuilder.Entity<TimeBankCardUsageLog>().Ignore(l => l.Client);
@@ -278,6 +283,9 @@ namespace Fitness_core_BE
modelBuilder.Entity<Booking>()
.HasIndex(b => new { b.FKFacilityId, b.StartTime, b.EndTime });
modelBuilder.Entity<BookingInvoice>()
.HasIndex(i => new { i.FKClientId, i.IssuedAt });
modelBuilder.Entity<UnlinkedDeviceEnrollment>()
.HasIndex(e => new { e.FKDeviceId, e.DeviceEmployeeNo, e.LinkedAt });