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:
@@ -65,7 +65,8 @@ namespace Fitness_core_BE.Controllers
|
|||||||
{
|
{
|
||||||
facilitySessionId = request.FacilitySessionId,
|
facilitySessionId = request.FacilitySessionId,
|
||||||
guestName = request.GuestName,
|
guestName = request.GuestName,
|
||||||
guestPhone = request.GuestPhone
|
guestPhone = request.GuestPhone,
|
||||||
|
customerId = request.CustomerId
|
||||||
}, HttpContext));
|
}, HttpContext));
|
||||||
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
@@ -79,7 +80,8 @@ namespace Fitness_core_BE.Controllers
|
|||||||
guestName = request.GuestName,
|
guestName = request.GuestName,
|
||||||
guestPhone = request.GuestPhone,
|
guestPhone = request.GuestPhone,
|
||||||
partySize = request.PartySize,
|
partySize = request.PartySize,
|
||||||
pricePerHour = request.PricePerHour
|
pricePerHour = request.PricePerHour,
|
||||||
|
customerId = request.CustomerId
|
||||||
}, HttpContext));
|
}, HttpContext));
|
||||||
|
|
||||||
// The actual "copy the link and share it" page -- BookingDto.ShareUrl points here.
|
// The actual "copy the link and share it" page -- BookingDto.ShareUrl points here.
|
||||||
@@ -135,7 +137,12 @@ namespace Fitness_core_BE.Controllers
|
|||||||
[RequiresPermission("bookings")]
|
[RequiresPermission("bookings")]
|
||||||
[HttpPost("api/client/bookings/{id:guid}/mark-paid")]
|
[HttpPost("api/client/bookings/{id:guid}/mark-paid")]
|
||||||
public async Task<ActionResult<ApiResponse<object>>> MarkGuestBookingPaid(Guid id, [FromBody] MarkGuestBookingPaidRequest request)
|
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]
|
[CustomMiddleware]
|
||||||
[TenantResolution]
|
[TenantResolution]
|
||||||
@@ -180,7 +187,44 @@ namespace Fitness_core_BE.Controllers
|
|||||||
[TenantResolution]
|
[TenantResolution]
|
||||||
[RequiresPermission("bookings")]
|
[RequiresPermission("bookings")]
|
||||||
[HttpPost("api/client/bookings/{id:guid}/complete")]
|
[HttpPost("api/client/bookings/{id:guid}/complete")]
|
||||||
public async Task<ActionResult<ApiResponse<object>>> CompleteBooking(Guid id)
|
public async Task<ActionResult<ApiResponse<object>>> CompleteBooking(Guid id, [FromBody] CompleteBookingRequest? request)
|
||||||
=> Response(await _bookingService.CompleteBooking(new { bookingId = id }, HttpContext));
|
=> 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,9 @@ namespace Fitness_core_BE.DTOs.Booking
|
|||||||
|
|
||||||
public string PaymentStatus { get; set; } = string.Empty;
|
public string PaymentStatus { get; set; } = string.Empty;
|
||||||
public decimal AmountCharged { get; set; }
|
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 string Status { get; set; } = string.Empty;
|
||||||
public DateTime BookedAt { get; set; }
|
public DateTime BookedAt { get; set; }
|
||||||
public DateTime? CheckedInAt { get; set; }
|
public DateTime? CheckedInAt { get; set; }
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,11 @@ namespace Fitness_core_BE.DTOs.Booking
|
|||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public string GuestPhone { get; set; } = string.Empty;
|
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
|
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>
|
/// the FE pre-fills this from the facility but lets staff adjust it.</summary>
|
||||||
[Range(typeof(decimal), "0", "79228162514264337593543950335", ErrorMessage = "PricePerHour cannot be negative")]
|
[Range(typeof(decimal), "0", "79228162514264337593543950335", ErrorMessage = "PricePerHour cannot be negative")]
|
||||||
public decimal? PricePerHour { get; set; }
|
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
|
public class MarkGuestBookingPaidRequest
|
||||||
@@ -67,6 +76,20 @@ namespace Fitness_core_BE.DTOs.Booking
|
|||||||
[Required]
|
[Required]
|
||||||
[Range(0.01, double.MaxValue, ErrorMessage = "AmountCharged must be greater than zero")]
|
[Range(0.01, double.MaxValue, ErrorMessage = "AmountCharged must be greater than zero")]
|
||||||
public decimal AmountCharged { get; set; }
|
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
|
public class CheckInBookingRequest
|
||||||
|
|||||||
@@ -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 ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
Task<List<Booking>> GetBookings(Guid clientId, Guid? facilityId, Guid? facilitySessionId, DateTime? fromUtc, DateTime? toUtc, string? status);
|
||||||
|
|
||||||
// --- Lazy overdue reconciliation (BookingService.SweepOverdueBookingsAsync), run at the
|
// --- 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
|
/// <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>
|
/// to NoShow. Returns the number of rows affected (informational/logging only).</summary>
|
||||||
Task<int> MarkOverdueConfirmedAsNoShowAsync(Guid clientId, DateTime now);
|
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
|
/// <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
|
/// 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
|
/// win), so this uses a conditional bulk update rather than read-then-write. Returns
|
||||||
/// false if the tag was no longer Available.</summary>
|
/// false if the tag was no longer Available.</summary>
|
||||||
Task<bool> TryClaimRfidTagAsync(Guid tagId, DateTime now);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ namespace Fitness_core_BE.Interfaces
|
|||||||
// gym's own schema) -- no clientId parameter needed.
|
// gym's own schema) -- no clientId parameter needed.
|
||||||
Task<TenantCustomerLink?> FindActiveLinkByIdentifierAsync(string identifier);
|
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);
|
Task<bool> DeleteCustomer(Guid customerId);
|
||||||
// Client-initiated delete: deactivates only this customer's presence at the calling
|
// Client-initiated delete: deactivates only this customer's presence at the calling
|
||||||
// client's gym (subscriptions/members/plan assignments scoped to clientId). The shared
|
// client's gym (subscriptions/members/plan assignments scoped to clientId). The shared
|
||||||
|
|||||||
@@ -43,8 +43,10 @@ namespace Fitness_core_BE.Interfaces.Services
|
|||||||
/// <summary>Staff manually flags a Confirmed, never-arrived booking.</summary>
|
/// <summary>Staff manually flags a Confirmed, never-arrived booking.</summary>
|
||||||
Task<object> MarkNoShow(object payload, HttpContext httpContext);
|
Task<object> MarkNoShow(object payload, HttpContext httpContext);
|
||||||
|
|
||||||
/// <summary>Staff manually ends a CheckedIn booking early (before the lazy sweep would
|
/// <summary>Staff checks out a CheckedIn booking, releasing its RFID tag if any. A guest
|
||||||
/// catch it), releasing its RFID tag if any.</summary>
|
/// 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> CompleteBooking(object payload, HttpContext httpContext);
|
||||||
|
|
||||||
Task<object> GetMyBookings(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>
|
/// <summary>Unified roster covering both booking modes.</summary>
|
||||||
Task<object> GetBookings(object payload, HttpContext httpContext);
|
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
@@ -86,24 +86,34 @@ namespace Fitness_core_BE.Models
|
|||||||
public string PaymentStatus { get; set; } = "NotRequired";
|
public string PaymentStatus { get; set; } = "NotRequired";
|
||||||
public decimal AmountCharged { get; set; } = 0m;
|
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 & 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
|
// Set only once payment is confirmed for a pay-per-session guest booking, or
|
||||||
// immediately for a member session (no payment gate) -- see PaymentStatus.
|
// immediately for a member session (no payment gate) -- see PaymentStatus.
|
||||||
public Guid? FKPaymentTransactionId { get; set; }
|
public Guid? FKPaymentTransactionId { get; set; }
|
||||||
[ForeignKey(nameof(FKPaymentTransactionId))]
|
[ForeignKey(nameof(FKPaymentTransactionId))]
|
||||||
public PaymentTransaction? PaymentTransaction { get; set; }
|
public PaymentTransaction? PaymentTransaction { get; set; }
|
||||||
|
|
||||||
/// <summary>"Confirmed" | "CheckedIn" | "Cancelled" | "NoShow" | "Completed". NoShow and
|
/// <summary>"Confirmed" | "CheckedIn" | "Cancelled" | "NoShow" | "Completed". NoShow is
|
||||||
/// Completed are reached either by explicit staff action (MarkNoShow/Complete) or lazily,
|
/// reached either by explicit staff action (MarkNoShow) or lazily, swept by
|
||||||
/// swept by BookingService.SweepOverdueBookingsAsync at the top of every check-in call
|
/// BookingService.SweepOverdueBookingsAsync at the top of every check-in or checkout call
|
||||||
/// (not a background job -- overdue bookings are reconciled the next time any check-in
|
/// (not a background job -- overdue Confirmed bookings are reconciled the next time any
|
||||||
/// happens for this client, across every facility).</summary>
|
/// 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 string Status { get; set; } = "Confirmed";
|
||||||
|
|
||||||
public DateTime BookedAt { get; set; } = DateTime.UtcNow;
|
public DateTime BookedAt { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime? CheckedInAt { get; set; }
|
public DateTime? CheckedInAt { get; set; }
|
||||||
/// <summary>Set when the booking reaches Completed, whether via the manual Complete
|
/// <summary>Set when the booking reaches Completed via the manual Complete action -- the
|
||||||
/// action or the lazy overdue-sweep -- the actual moment it was reconciled, not
|
/// actual moment it was checked out, not necessarily EndTime.</summary>
|
||||||
/// necessarily EndTime.</summary>
|
|
||||||
public DateTime? CheckedOutAt { get; set; }
|
public DateTime? CheckedOutAt { get; set; }
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,12 +81,6 @@ namespace Fitness_core_BE.Repos
|
|||||||
.Where(b => b.FKClientId == clientId && b.Status == "Confirmed" && b.EndTime < now)
|
.Where(b => b.FKClientId == clientId && b.Status == "Confirmed" && b.EndTime < now)
|
||||||
.ExecuteUpdateAsync(s => s.SetProperty(b => b.Status, "NoShow"));
|
.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)
|
public async Task<bool> TryClaimRfidTagAsync(Guid tagId, DateTime now)
|
||||||
{
|
{
|
||||||
var rows = await _context.RfidTags
|
var rows = await _context.RfidTags
|
||||||
@@ -96,5 +90,52 @@ namespace Fitness_core_BE.Repos
|
|||||||
.SetProperty(t => t.UpdatedAt, now));
|
.SetProperty(t => t.UpdatedAt, now));
|
||||||
return rows > 0;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,13 @@ namespace Fitness_core_BE.Repos
|
|||||||
(l.PrimaryPhoneCached == id || l.EmailCached == id));
|
(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)
|
public async Task<Customer?> UpdateCustomer(Guid customerId, Customer updatedCustomer)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -82,8 +82,32 @@ namespace Fitness_core_BE.Repos
|
|||||||
})
|
})
|
||||||
.ToListAsync(ct);
|
.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(platformInvoices);
|
||||||
memberPayments.AddRange(staffPayments);
|
memberPayments.AddRange(staffPayments);
|
||||||
|
memberPayments.AddRange(bookingPayments);
|
||||||
return memberPayments;
|
return memberPayments;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+143
-18
@@ -14,6 +14,7 @@ namespace Fitness_core_BE.Services
|
|||||||
IFacilityRepository facilityRepository,
|
IFacilityRepository facilityRepository,
|
||||||
IRfidTagRepository rfidTagRepository,
|
IRfidTagRepository rfidTagRepository,
|
||||||
ICustomerSubscriptionRepository customerSubscriptionRepository,
|
ICustomerSubscriptionRepository customerSubscriptionRepository,
|
||||||
|
ICustomerRepository customerRepository,
|
||||||
IUnitOfWork uow,
|
IUnitOfWork uow,
|
||||||
UserRoleHelper userRoleHelper) : IBookingService
|
UserRoleHelper userRoleHelper) : IBookingService
|
||||||
{
|
{
|
||||||
@@ -21,6 +22,7 @@ namespace Fitness_core_BE.Services
|
|||||||
private readonly IFacilityRepository _facilityRepository = facilityRepository;
|
private readonly IFacilityRepository _facilityRepository = facilityRepository;
|
||||||
private readonly IRfidTagRepository _rfidTagRepository = rfidTagRepository;
|
private readonly IRfidTagRepository _rfidTagRepository = rfidTagRepository;
|
||||||
private readonly ICustomerSubscriptionRepository _customerSubscriptionRepository = customerSubscriptionRepository;
|
private readonly ICustomerSubscriptionRepository _customerSubscriptionRepository = customerSubscriptionRepository;
|
||||||
|
private readonly ICustomerRepository _customerRepository = customerRepository;
|
||||||
private readonly IUnitOfWork _uow = uow;
|
private readonly IUnitOfWork _uow = uow;
|
||||||
private readonly UserRoleHelper _userRoleHelper = userRoleHelper;
|
private readonly UserRoleHelper _userRoleHelper = userRoleHelper;
|
||||||
|
|
||||||
@@ -113,6 +115,7 @@ namespace Fitness_core_BE.Services
|
|||||||
var facilitySessionId = Guid.Parse(data["facilitySessionId"].GetString()!);
|
var facilitySessionId = Guid.Parse(data["facilitySessionId"].GetString()!);
|
||||||
var guestName = data["guestName"].GetString() ?? throw new ArgumentException("guestName is required");
|
var guestName = data["guestName"].GetString() ?? throw new ArgumentException("guestName is required");
|
||||||
var guestPhone = data["guestPhone"].GetString() ?? throw new ArgumentException("guestPhone is required");
|
var guestPhone = data["guestPhone"].GetString() ?? throw new ArgumentException("guestPhone is required");
|
||||||
|
var customerId = await ResolveOptionalCustomerLinkAsync(data);
|
||||||
|
|
||||||
var session = await _facilityRepository.GetFacilitySessionById(facilitySessionId)
|
var session = await _facilityRepository.GetFacilitySessionById(facilitySessionId)
|
||||||
?? throw new KeyNotFoundException("Facility session not found");
|
?? throw new KeyNotFoundException("Facility session not found");
|
||||||
@@ -135,6 +138,7 @@ namespace Fitness_core_BE.Services
|
|||||||
EndTime = session.EndTime,
|
EndTime = session.EndTime,
|
||||||
FKClientId = session.FKClientId,
|
FKClientId = session.FKClientId,
|
||||||
IsGuest = true,
|
IsGuest = true,
|
||||||
|
FKCustomerId = customerId,
|
||||||
GuestName = guestName,
|
GuestName = guestName,
|
||||||
GuestPhone = guestPhone,
|
GuestPhone = guestPhone,
|
||||||
OtpCode = GenerateOtp(),
|
OtpCode = GenerateOtp(),
|
||||||
@@ -207,6 +211,7 @@ namespace Fitness_core_BE.Services
|
|||||||
var data = DeserializePayload.Deserialize(payload);
|
var data = DeserializePayload.Deserialize(payload);
|
||||||
var guestName = data["guestName"].GetString() ?? throw new ArgumentException("guestName is required");
|
var guestName = data["guestName"].GetString() ?? throw new ArgumentException("guestName is required");
|
||||||
var guestPhone = data["guestPhone"].GetString() ?? throw new ArgumentException("guestPhone is required");
|
var guestPhone = data["guestPhone"].GetString() ?? throw new ArgumentException("guestPhone is required");
|
||||||
|
var customerId = await ResolveOptionalCustomerLinkAsync(data);
|
||||||
|
|
||||||
var durationHours = (decimal)(endTime - startTime).TotalHours;
|
var durationHours = (decimal)(endTime - startTime).TotalHours;
|
||||||
|
|
||||||
@@ -220,6 +225,7 @@ namespace Fitness_core_BE.Services
|
|||||||
PartySize = partySize,
|
PartySize = partySize,
|
||||||
FKClientId = facility.FKClientId,
|
FKClientId = facility.FKClientId,
|
||||||
IsGuest = true,
|
IsGuest = true,
|
||||||
|
FKCustomerId = customerId,
|
||||||
GuestName = guestName,
|
GuestName = guestName,
|
||||||
GuestPhone = guestPhone,
|
GuestPhone = guestPhone,
|
||||||
OtpCode = GenerateOtp(),
|
OtpCode = GenerateOtp(),
|
||||||
@@ -283,6 +289,22 @@ namespace Fitness_core_BE.Services
|
|||||||
return (facility, startTime, endTime, partySize, pricePerHour);
|
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 ----------
|
// ---------- Payment / check-in / lifecycle ----------
|
||||||
|
|
||||||
public async Task<object> MarkGuestBookingPaid(object payload, HttpContext httpContext)
|
public async Task<object> MarkGuestBookingPaid(object payload, HttpContext httpContext)
|
||||||
@@ -293,6 +315,8 @@ namespace Fitness_core_BE.Services
|
|||||||
var data = DeserializePayload.Deserialize(payload);
|
var data = DeserializePayload.Deserialize(payload);
|
||||||
var bookingId = Guid.Parse(data["bookingId"].GetString()!);
|
var bookingId = Guid.Parse(data["bookingId"].GetString()!);
|
||||||
var amountCharged = data["amountCharged"].GetDecimal();
|
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)
|
var booking = await _bookingRepository.GetBookingById(bookingId)
|
||||||
?? throw new KeyNotFoundException("Booking not found");
|
?? throw new KeyNotFoundException("Booking not found");
|
||||||
@@ -301,6 +325,9 @@ namespace Fitness_core_BE.Services
|
|||||||
|
|
||||||
booking.PaymentStatus = "Paid";
|
booking.PaymentStatus = "Paid";
|
||||||
booking.AmountCharged = amountCharged;
|
booking.AmountCharged = amountCharged;
|
||||||
|
booking.PaymentMethod = paymentMethod;
|
||||||
|
booking.PaidAt = DateTime.UtcNow;
|
||||||
|
await CreateBookingInvoiceAsync(booking, paymentMethod);
|
||||||
|
|
||||||
await _uow.CommitAsync();
|
await _uow.CommitAsync();
|
||||||
return MapToDto(booking, includeOtp: false);
|
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:
|
/// <summary>Lazily reconciles what a background job would otherwise have swept: Confirmed
|
||||||
/// Confirmed bookings whose window passed with no check-in become NoShow; CheckedIn
|
/// bookings whose window passed with no check-in become NoShow. Runs at the top of every
|
||||||
/// bookings whose window passed become Completed and release their tag. Runs at the top
|
/// check-in AND every checkout (Complete), across every facility for this client (not
|
||||||
/// of every check-in, across every facility for this client (not scoped to whichever
|
/// scoped to whichever facility is being acted on right now), so the whole roster stays
|
||||||
/// facility is being checked into right now), so the whole roster stays accurate as long
|
/// accurate as long as staff keep interacting with bookings anywhere in the club.
|
||||||
/// as check-ins keep happening anywhere in the club.</summary>
|
/// 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)
|
private async Task SweepOverdueBookingsAsync(Guid clientId, DateTime now)
|
||||||
{
|
{
|
||||||
await _bookingRepository.MarkOverdueConfirmedAsNoShowAsync(clientId, now);
|
await _bookingRepository.MarkOverdueConfirmedAsNoShowAsync(clientId, now);
|
||||||
|
// Caller's _uow.CommitAsync() persists this alongside its own changes.
|
||||||
|
}
|
||||||
|
|
||||||
var overdueCheckedIn = await _bookingRepository.GetOverdueCheckedInBookingsAsync(clientId, now);
|
/// <summary>Snapshots a receipt the moment a booking's payment is recorded (from
|
||||||
foreach (var booking in overdueCheckedIn)
|
/// 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";
|
FKBookingId = booking.BookingId,
|
||||||
booking.CheckedOutAt = now;
|
FKClientId = booking.FKClientId,
|
||||||
if (booking.RfidTag != null && booking.RfidReturnedAt == null)
|
Amount = booking.AmountCharged,
|
||||||
{
|
PaymentMethod = paymentMethod,
|
||||||
booking.RfidTag.Status = "Available";
|
GuestName = booking.GuestName ?? string.Empty,
|
||||||
booking.RfidTag.UpdatedAt = now;
|
GuestPhone = booking.GuestPhone,
|
||||||
booking.RfidReturnedAt = now;
|
FacilityName = booking.Facility?.Name ?? string.Empty,
|
||||||
}
|
BookingStartTime = booking.StartTime,
|
||||||
}
|
BookingEndTime = booking.EndTime
|
||||||
// Caller's _uow.CommitAsync() persists these tracked changes alongside its own.
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<object> CancelBooking(object payload, HttpContext httpContext)
|
public async Task<object> CancelBooking(object payload, HttpContext httpContext)
|
||||||
@@ -572,6 +610,9 @@ namespace Fitness_core_BE.Services
|
|||||||
await _uow.BeginAsync();
|
await _uow.BeginAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
|
||||||
|
await SweepOverdueBookingsAsync(clientId, DateTime.UtcNow);
|
||||||
|
|
||||||
var data = DeserializePayload.Deserialize(payload);
|
var data = DeserializePayload.Deserialize(payload);
|
||||||
var bookingId = Guid.Parse(data["bookingId"].GetString()!);
|
var bookingId = Guid.Parse(data["bookingId"].GetString()!);
|
||||||
|
|
||||||
@@ -580,6 +621,28 @@ namespace Fitness_core_BE.Services
|
|||||||
if (booking.Status != "CheckedIn")
|
if (booking.Status != "CheckedIn")
|
||||||
throw new ArgumentException("Only a CheckedIn booking can be completed");
|
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;
|
var now = DateTime.UtcNow;
|
||||||
booking.Status = "Completed";
|
booking.Status = "Completed";
|
||||||
booking.CheckedOutAt = now;
|
booking.CheckedOutAt = now;
|
||||||
@@ -655,6 +718,48 @@ namespace Fitness_core_BE.Services
|
|||||||
return MapToDto(booking, includeOtp: !booking.OtpVerifiedAt.HasValue);
|
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();
|
private static string GenerateOtp() => RandomNumberGenerator.GetInt32(100000, 1000000).ToString();
|
||||||
|
|
||||||
// Built from the request itself (scheme/host), not a config value, so the link is
|
// 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)
|
private static string BuildShareUrl(HttpContext httpContext, Guid bookingId)
|
||||||
=> $"{httpContext.Request.Scheme}://{httpContext.Request.Host}/api/public/bookings/{bookingId}/view";
|
=> $"{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()
|
private static BookingDto MapToDto(Booking booking, bool includeOtp, string? shareUrl = null) => new()
|
||||||
{
|
{
|
||||||
BookingId = booking.BookingId,
|
BookingId = booking.BookingId,
|
||||||
@@ -685,11 +793,28 @@ namespace Fitness_core_BE.Services
|
|||||||
RfidReturnedAt = booking.RfidReturnedAt,
|
RfidReturnedAt = booking.RfidReturnedAt,
|
||||||
PaymentStatus = booking.PaymentStatus,
|
PaymentStatus = booking.PaymentStatus,
|
||||||
AmountCharged = booking.AmountCharged,
|
AmountCharged = booking.AmountCharged,
|
||||||
|
PaymentMethod = booking.PaymentMethod,
|
||||||
|
PaidAt = booking.PaidAt,
|
||||||
Status = booking.Status,
|
Status = booking.Status,
|
||||||
BookedAt = booking.BookedAt,
|
BookedAt = booking.BookedAt,
|
||||||
CheckedInAt = booking.CheckedInAt,
|
CheckedInAt = booking.CheckedInAt,
|
||||||
CheckedOutAt = booking.CheckedOutAt,
|
CheckedOutAt = booking.CheckedOutAt,
|
||||||
ShareUrl = shareUrl
|
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
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ namespace Fitness_core_BE
|
|||||||
// TimeBankCard's own, unrelated RfidTagId string field.
|
// TimeBankCard's own, unrelated RfidTagId string field.
|
||||||
public DbSet<RfidTag> RfidTags => Set<RfidTag>();
|
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)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
base.OnModelCreating(modelBuilder);
|
base.OnModelCreating(modelBuilder);
|
||||||
@@ -184,6 +188,7 @@ namespace Fitness_core_BE
|
|||||||
modelBuilder.Entity<Facility>().Ignore(f => f.Client);
|
modelBuilder.Entity<Facility>().Ignore(f => f.Client);
|
||||||
modelBuilder.Entity<FacilitySession>().Ignore(s => s.Client);
|
modelBuilder.Entity<FacilitySession>().Ignore(s => s.Client);
|
||||||
modelBuilder.Entity<Booking>().Ignore(b => b.Client);
|
modelBuilder.Entity<Booking>().Ignore(b => b.Client);
|
||||||
|
modelBuilder.Entity<BookingInvoice>().Ignore(i => i.Client);
|
||||||
modelBuilder.Entity<TimeBankCard>().Ignore(t => t.Client);
|
modelBuilder.Entity<TimeBankCard>().Ignore(t => t.Client);
|
||||||
modelBuilder.Entity<TimeBankCardUsageLog>().Ignore(l => l.Client);
|
modelBuilder.Entity<TimeBankCardUsageLog>().Ignore(l => l.Client);
|
||||||
|
|
||||||
@@ -278,6 +283,9 @@ namespace Fitness_core_BE
|
|||||||
modelBuilder.Entity<Booking>()
|
modelBuilder.Entity<Booking>()
|
||||||
.HasIndex(b => new { b.FKFacilityId, b.StartTime, b.EndTime });
|
.HasIndex(b => new { b.FKFacilityId, b.StartTime, b.EndTime });
|
||||||
|
|
||||||
|
modelBuilder.Entity<BookingInvoice>()
|
||||||
|
.HasIndex(i => new { i.FKClientId, i.IssuedAt });
|
||||||
|
|
||||||
modelBuilder.Entity<UnlinkedDeviceEnrollment>()
|
modelBuilder.Entity<UnlinkedDeviceEnrollment>()
|
||||||
.HasIndex(e => new { e.FKDeviceId, e.DeviceEmployeeNo, e.LinkedAt });
|
.HasIndex(e => new { e.FKDeviceId, e.DeviceEmployeeNo, e.LinkedAt });
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user