Facility booking-mode redesign, multi-plan subscriptions, and Classes

Implements the facility-classes-booking-dashboard plan (backend):

- Facility.BookingMode (single Scheduled/AdHoc choice) replaced with
  independent SupportsClasses/SupportsBooking flags, so one facility
  can run a class and accept walk-in bookings; a running class now
  blocks walk-in booking for its window on both create and reschedule.
- CustomerPlan.GrantsGymAccess + CustomerPlanClass join table let a
  customer hold one gym-access plan plus any number of class-only
  plans. Replaced the old single-subscription resolution
  (GetActiveSubscriptionByCustomerId/HasAnyActiveSubscriptionAsync)
  with gym-access-aware and class-aware resolvers across booking
  creation/cancellation, plan assignment, and customer self-service.
  Fixed ChangeMembershipPlan/renewal plan-changes, which resolved "the
  subscription to replace" by most-recent-created and would have
  deactivated a member's class subscription instead of their gym one.
- New FacilityClass model: recurring class definitions (Daily/Weekly/
  Monthly/Custom) with lazy occurrence generation (8-week horizon,
  race-safe via a unique index) and proper unlimited-capacity handling.
  "Mark not held" replaces the old bare-cancel: cascades to cancel and
  refund every confirmed booking on the occurrence through the same
  core CancelBooking uses, and frees the slot for walk-in booking.
  Scheduling conflicts (a walk-in booking now overlapping a class) are
  surfaced for staff to resolve via the existing Reschedule flow.
- Renamed the pre-existing "AdHoc" booking-mode terminology to
  "WalkIn" throughout (routes, methods, DTOs, the stored BookingMode
  value) for a clearer, simpler name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 14:26:59 +05:30
parent 1a502767ba
commit 5d711bd13e
38 changed files with 1437 additions and 226 deletions
+31 -14
View File
@@ -440,13 +440,27 @@ namespace Fitness_core_BE.Services.CustomerService
// member is created without a plan — e.g. a group secondary added later).
if (data.ContainsKey("customerPlanId") && data["customerPlanId"].ValueKind != JsonValueKind.Null)
{
var customerPlanId = Guid.Parse(data["customerPlanId"].GetString()!);
// A class-linked plan (created from the Classes tab's "Manage Plan" action)
// stacks freely with other class-only plans and with one gym-access plan --
// only a second GYM-ACCESS subscription is blocked. See
// CustomerPlan.GrantsGymAccess.
var grantsGymAccess = await _customerSubscriptionRepository.PlanGrantsGymAccessAsync(customerPlanId);
// Guard against duplicate active subscriptions at this gym: callers like
// the create-existing-customer and edit-customer flows submit whatever plan
// is currently shown on the form, not necessarily a deliberate "assign a new
// plan" action, so re-submitting an already-assigned plan must not stack a
// second active subscription. Assigning a genuinely different plan belongs to
// ChangeMembershipPlan, which explicitly deactivates the old one first.
var existingActive = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(customerId);
// second active subscription. Assigning a genuinely different GYM plan
// belongs to ChangeMembershipPlan, which explicitly deactivates the old one
// first; a genuinely different CLASS plan is just a new, additional
// subscription (handled below) -- it's only a duplicate of itself that's
// blocked here.
var existingActive = grantsGymAccess
? await _customerSubscriptionRepository.GetActiveGymAccessSubscriptionAsync(customerId)
: await _customerSubscriptionRepository.GetActiveSubscriptionForPlanAsync(customerId, customerPlanId);
if (existingActive != null)
{
alreadyAssigned = true;
@@ -457,15 +471,18 @@ namespace Fitness_core_BE.Services.CustomerService
}
else
{
// Not primary anywhere (checked above), but could still be an active
// secondary on someone else's group plan -- GetActiveSubscriptionByCustomerId
// can't see that (it only matches CustomerSubscription.FKCustomerId, the
// primary). One customer, one plan at a time either way.
var hasOtherActivePlan = await _customerSubscriptionRepository.HasAnyActiveSubscriptionAsync(customerId);
if (hasOtherActivePlan)
throw new ArgumentException("This customer already has an active plan. Remove them from it before assigning a new one.");
if (grantsGymAccess)
{
// Not primary anywhere (checked above), but could still be an active
// secondary on someone else's group plan -- GetActiveGymAccessSubscriptionAsync
// can't see that (it only matches CustomerSubscription.FKCustomerId, the
// primary). One customer, one GYM-ACCESS plan at a time; class-only
// plans are unrestricted.
var hasOtherActiveGymPlan = await _customerSubscriptionRepository.HasActiveGymAccessPlanAsync(customerId);
if (hasOtherActiveGymPlan)
throw new ArgumentException("This customer already has an active plan. Remove them from it before assigning a new one.");
}
var customerPlanId = Guid.Parse(data["customerPlanId"].GetString()!);
var onboardDate = DataExtractor.GetOptionalDateTime(data, "onboardDate");
var chargePayment = DataExtractor.GetOptionalBool(data, "chargePayment") ?? true;
@@ -1403,7 +1420,7 @@ namespace Fitness_core_BE.Services.CustomerService
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid cusId = await _userRoleHelper.GetCusId(userId);
var subscription = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(cusId);
var subscription = await _customerSubscriptionRepository.GetActiveGymAccessSubscriptionAsync(cusId);
if (subscription == null)
return new { hasSubscription = false };
@@ -1477,7 +1494,7 @@ namespace Fitness_core_BE.Services.CustomerService
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid cusId = await _userRoleHelper.GetCusId(userId);
var customerSubscription = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(cusId);
var customerSubscription = await _customerSubscriptionRepository.GetActiveGymAccessSubscriptionAsync(cusId);
if (customerSubscription != null)
{
@@ -1539,7 +1556,7 @@ namespace Fitness_core_BE.Services.CustomerService
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid cusId = await _userRoleHelper.GetCusId(userId);
var subscription = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(cusId);
var subscription = await _customerSubscriptionRepository.GetActiveGymAccessSubscriptionAsync(cusId);
if (subscription == null)
throw new KeyNotFoundException("You don't have an active gym to unfollow.");