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:
+31
-14
@@ -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.");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user