5d711bd13e
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>
515 lines
22 KiB
C#
515 lines
22 KiB
C#
using Fitness_core_BE.Domain;
|
|
using Fitness_core_BE.Helpers;
|
|
using Fitness_core_BE.Interfaces;
|
|
using Fitness_core_BE.Interfaces.Services;
|
|
using Fitness_core_BE.Models;
|
|
using Fitness_core_BE.Infra.UoW;
|
|
using Fitness_core_BE.DTOs.CustomerSubscription;
|
|
using System.Text.Json;
|
|
|
|
namespace Fitness_core_BE.Services.CustomerSubscription
|
|
{
|
|
public class CustomerSubscriptionService : ICustomerSubscriptionService
|
|
{
|
|
private readonly ICustomerSubscriptionRepository _customerSubscriptionRepository;
|
|
private readonly ICustomerInvoiceRepository _customerInvoiceRepository;
|
|
private readonly IUnitOfWork _uow;
|
|
private readonly UserRoleHelper _userRoleHelper;
|
|
|
|
public CustomerSubscriptionService(
|
|
ICustomerSubscriptionRepository customerSubscriptionRepository,
|
|
ICustomerInvoiceRepository customerInvoiceRepository,
|
|
IUnitOfWork uow,
|
|
UserRoleHelper userRoleHelper)
|
|
{
|
|
_customerSubscriptionRepository = customerSubscriptionRepository;
|
|
_customerInvoiceRepository = customerInvoiceRepository;
|
|
_uow = uow;
|
|
_userRoleHelper = userRoleHelper;
|
|
}
|
|
|
|
public async Task<object> CreateCustomerSubscription(object payload, HttpContext httpContext)
|
|
{
|
|
await _uow.BeginAsync();
|
|
try
|
|
{
|
|
var data = DeserializePayload.Deserialize(payload);
|
|
|
|
if (!data!.ContainsKey("customerId"))
|
|
{
|
|
throw new KeyNotFoundException("customerId is required");
|
|
}
|
|
|
|
if (!data.ContainsKey("customerPlanId"))
|
|
{
|
|
throw new KeyNotFoundException("customerPlanId is required");
|
|
}
|
|
|
|
var customerId = Guid.Parse(data["customerId"].GetString()!);
|
|
var customerPlanId = Guid.Parse(data["customerPlanId"].GetString()!);
|
|
|
|
// Get plan details for creating the initial sub-item
|
|
var plan = await _customerSubscriptionRepository
|
|
.GetPlanByIdAsync(customerPlanId);
|
|
if (plan == null)
|
|
{
|
|
throw new KeyNotFoundException("Customer Plan not found");
|
|
}
|
|
|
|
// Create subscription
|
|
var subscription = new Models.CustomerSubscription
|
|
{
|
|
FKCustomerId = customerId,
|
|
FKCustomerPlanId = customerPlanId,
|
|
OnboardDate = DataExtractor.GetOptionalDateTime(data, "onboardDate") ?? DateTime.UtcNow,
|
|
ExpiryDate = DataExtractor.GetOptionalDateTime(data, "expiryDate"),
|
|
IsActive = true
|
|
};
|
|
|
|
// Create initial sub-item representing the main plan
|
|
var initialSubItem = new CustomerSubItem
|
|
{
|
|
SubItemName = plan.PlanName,
|
|
SubItemDescription = plan.PlanDescription,
|
|
SubItemPrice = plan.PlanPrice,
|
|
IsActive = true
|
|
};
|
|
|
|
var aggregate = CustomerSubscriptionFactory.Create(
|
|
subscription, plan, initialSubItem);
|
|
await _customerSubscriptionRepository.AddCustomerSubscriptionAsync(
|
|
aggregate.Subscription,
|
|
aggregate.InitialSubItem,
|
|
aggregate.PrimaryMember,
|
|
aggregate.Installments);
|
|
await _uow.CommitAsync();
|
|
|
|
return new
|
|
{
|
|
subscriptionId = subscription.SubscriptionId,
|
|
customerId = subscription.FKCustomerId,
|
|
customerPlanId = subscription.FKCustomerPlanId,
|
|
onboardDate = subscription.OnboardDate,
|
|
expiryDate = subscription.ExpiryDate,
|
|
isActive = subscription.IsActive,
|
|
message = "Customer subscription created successfully with initial plan item"
|
|
};
|
|
}
|
|
catch
|
|
{
|
|
await _uow.RollbackAsync();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task<object> GetCustomerSubscriptionById(object payload, HttpContext httpContext)
|
|
{
|
|
var data = DeserializePayload.Deserialize(payload);
|
|
|
|
if (!data!.ContainsKey("subscriptionId"))
|
|
{
|
|
throw new KeyNotFoundException("subscriptionId is required");
|
|
}
|
|
|
|
var subscriptionId = Guid.Parse(data["subscriptionId"].GetString()!);
|
|
var subscription = await _customerSubscriptionRepository.GetCustomerSubscriptionById(subscriptionId);
|
|
|
|
if (subscription == null)
|
|
{
|
|
throw new KeyNotFoundException("Customer Subscription not found");
|
|
}
|
|
|
|
// Get sub-items
|
|
var subItems = (await _customerSubscriptionRepository.GetSubItemsAsync(
|
|
subscriptionId, activeOnly: false, newestFirst: false))
|
|
.Select(ToSubItemDto)
|
|
.ToList();
|
|
|
|
return new CustomerSubscriptionDto
|
|
{
|
|
SubscriptionId = subscription.SubscriptionId,
|
|
CustomerId = subscription.FKCustomerId,
|
|
CustomerName = subscription.TenantCustomerLink?.DisplayName,
|
|
CustomerPlanId = subscription.FKCustomerPlanId,
|
|
PlanName = subscription.CustomerPlan?.PlanName,
|
|
PlanPrice = subscription.CustomerPlan?.PlanPrice ?? 0,
|
|
DurationInDays = subscription.CustomerPlan?.DurationInDays ?? 0,
|
|
OnboardDate = subscription.OnboardDate,
|
|
ExpiryDate = subscription.ExpiryDate,
|
|
RenewalDate = subscription.RenewalDate,
|
|
IsActive = subscription.IsActive,
|
|
CreatedAt = subscription.CreatedAt,
|
|
DiscountType = subscription.DiscountType,
|
|
DiscountValue = subscription.DiscountValue,
|
|
SubItems = subItems
|
|
};
|
|
}
|
|
|
|
public async Task<object> GetCustomerSubscriptionsByCustomerId(object payload, HttpContext httpContext)
|
|
{
|
|
var data = DeserializePayload.Deserialize(payload);
|
|
|
|
if (!data!.ContainsKey("customerId"))
|
|
{
|
|
throw new KeyNotFoundException("customerId is required");
|
|
}
|
|
|
|
var customerId = Guid.Parse(data["customerId"].GetString()!);
|
|
var subscriptions = await _customerSubscriptionRepository.GetCustomerSubscriptionsByCustomerId(customerId);
|
|
|
|
return subscriptions.Select(s => new
|
|
{
|
|
subscriptionId = s.SubscriptionId,
|
|
customerPlanId = s.FKCustomerPlanId,
|
|
planName = s.CustomerPlan?.PlanName,
|
|
planPrice = s.CustomerPlan?.PlanPrice,
|
|
durationInDays = s.CustomerPlan?.DurationInDays,
|
|
onboardDate = s.OnboardDate,
|
|
expiryDate = s.ExpiryDate,
|
|
renewalDate = s.RenewalDate,
|
|
isActive = s.IsActive,
|
|
createdAt = s.CreatedAt
|
|
}).ToList();
|
|
}
|
|
|
|
public async Task<object> GetActiveSubscriptionByCustomerId(object payload, HttpContext httpContext)
|
|
{
|
|
var data = DeserializePayload.Deserialize(payload);
|
|
|
|
if (!data!.ContainsKey("customerId"))
|
|
{
|
|
throw new KeyNotFoundException("customerId is required");
|
|
}
|
|
|
|
var customerId = Guid.Parse(data["customerId"].GetString()!);
|
|
var subscription = await _customerSubscriptionRepository.GetActiveGymAccessSubscriptionAsync(customerId);
|
|
|
|
if (subscription == null)
|
|
{
|
|
return new { message = "No active subscription found for this customer" };
|
|
}
|
|
|
|
// Get sub-items
|
|
var subItems = (await _customerSubscriptionRepository.GetSubItemsAsync(
|
|
subscription.SubscriptionId,
|
|
activeOnly: false,
|
|
newestFirst: false))
|
|
.Select(ToSubItemDto)
|
|
.ToList();
|
|
|
|
return new CustomerSubscriptionDto
|
|
{
|
|
SubscriptionId = subscription.SubscriptionId,
|
|
CustomerId = subscription.FKCustomerId,
|
|
CustomerName = subscription.TenantCustomerLink?.DisplayName,
|
|
CustomerPlanId = subscription.FKCustomerPlanId,
|
|
PlanName = subscription.CustomerPlan?.PlanName,
|
|
PlanPrice = subscription.CustomerPlan?.PlanPrice ?? 0,
|
|
DurationInDays = subscription.CustomerPlan?.DurationInDays ?? 0,
|
|
OnboardDate = subscription.OnboardDate,
|
|
ExpiryDate = subscription.ExpiryDate,
|
|
RenewalDate = subscription.RenewalDate,
|
|
IsActive = subscription.IsActive,
|
|
CreatedAt = subscription.CreatedAt,
|
|
DiscountType = subscription.DiscountType,
|
|
DiscountValue = subscription.DiscountValue,
|
|
SubItems = subItems
|
|
};
|
|
}
|
|
|
|
// ---------- Group membership & summaries (client dashboard) ----------
|
|
|
|
public async Task<object> GetSubscriptionSummary(object payload, HttpContext httpContext)
|
|
{
|
|
var data = DeserializePayload.Deserialize(payload);
|
|
var subscriptionId = Guid.Parse(data!["subscriptionId"].GetString()!);
|
|
|
|
var subscription = await LoadAuthorizedSubscription(subscriptionId, httpContext);
|
|
return await BuildSummaryAsync(subscription);
|
|
}
|
|
|
|
public async Task<object> AddMemberToSubscription(object payload, HttpContext httpContext)
|
|
{
|
|
await _uow.BeginAsync();
|
|
try
|
|
{
|
|
var data = DeserializePayload.Deserialize(payload);
|
|
var subscriptionId = Guid.Parse(data!["subscriptionId"].GetString()!);
|
|
var customerId = Guid.Parse(data["customerId"].GetString()!);
|
|
|
|
var subscription = await LoadAuthorizedSubscription(subscriptionId, httpContext);
|
|
var plan = subscription.CustomerPlan;
|
|
|
|
if (plan == null || plan.PlanScope != "Group")
|
|
throw new ArgumentException("Members can only be added to Group plans");
|
|
|
|
var activeCount = await _customerSubscriptionRepository.GetActiveMemberCountAsync(subscriptionId);
|
|
if (activeCount >= plan.MemberLimit)
|
|
throw new ArgumentException($"This plan allows at most {plan.MemberLimit} members");
|
|
|
|
var customerExists = await _customerSubscriptionRepository
|
|
.IsActiveCustomerAsync(customerId);
|
|
if (!customerExists)
|
|
throw new KeyNotFoundException("Customer not found");
|
|
|
|
var alreadyMember = await _customerSubscriptionRepository
|
|
.GetActiveSubscriptionMemberByCustomerAsync(subscriptionId, customerId);
|
|
if (alreadyMember != null)
|
|
throw new ArgumentException("This customer is already a member of the subscription");
|
|
|
|
// One customer, one GYM-ACCESS plan at a time -- catches both "already primary
|
|
// on their own plan" and "already a secondary on a different group", neither of
|
|
// which the check above sees (it only looks at this one subscription). Group
|
|
// plans are ordinary gym plans (no class links), so this is always the
|
|
// gym-access check in practice; left as such rather than a generic
|
|
// HasAnyActiveSubscriptionAsync so a class-only subscription elsewhere never
|
|
// blocks joining a group.
|
|
var hasOtherActiveGymPlan = await _customerSubscriptionRepository.HasActiveGymAccessPlanAsync(customerId);
|
|
if (hasOtherActiveGymPlan)
|
|
throw new ArgumentException("This customer already has an active plan. Remove them from it before adding them to another.");
|
|
|
|
var member = await _customerSubscriptionRepository.AddSubscriptionMemberAsync(new SubscriptionMember
|
|
{
|
|
FKSubscriptionId = subscriptionId,
|
|
FKCustomerId = customerId,
|
|
IsPrimary = false
|
|
});
|
|
|
|
await _uow.CommitAsync();
|
|
|
|
return new
|
|
{
|
|
success = true,
|
|
subscriptionMemberId = member.SubscriptionMemberId,
|
|
memberCount = activeCount + 1,
|
|
memberLimit = plan.MemberLimit
|
|
};
|
|
}
|
|
catch
|
|
{
|
|
await _uow.RollbackAsync();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task<object> RemoveMemberFromSubscription(object payload, HttpContext httpContext)
|
|
{
|
|
await _uow.BeginAsync();
|
|
try
|
|
{
|
|
var data = DeserializePayload.Deserialize(payload);
|
|
var subscriptionId = Guid.Parse(data!["subscriptionId"].GetString()!);
|
|
var subscriptionMemberId = Guid.Parse(data["subscriptionMemberId"].GetString()!);
|
|
|
|
await LoadAuthorizedSubscription(subscriptionId, httpContext);
|
|
|
|
var member = await _customerSubscriptionRepository.GetSubscriptionMemberAsync(subscriptionMemberId);
|
|
if (member == null || member.FKSubscriptionId != subscriptionId || !member.IsActive)
|
|
throw new KeyNotFoundException("Subscription member not found");
|
|
|
|
if (member.IsPrimary)
|
|
throw new ArgumentException("The primary member cannot be removed from a subscription");
|
|
|
|
member.IsActive = false;
|
|
|
|
await _uow.CommitAsync();
|
|
return new { success = true };
|
|
}
|
|
catch
|
|
{
|
|
await _uow.RollbackAsync();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>Typed variant used by ClientService.GetCustomerDetailsById (covers group secondaries).</summary>
|
|
public async Task<List<SubscriptionSummaryDto>> BuildSummariesForCustomerAsync(Guid customerId)
|
|
{
|
|
var subscriptions = await _customerSubscriptionRepository.GetSubscriptionsForCustomerAsync(customerId);
|
|
var summaries = new List<SubscriptionSummaryDto>(subscriptions.Count);
|
|
foreach (var subscription in subscriptions)
|
|
{
|
|
summaries.Add(await BuildSummaryAsync(subscription));
|
|
}
|
|
return summaries;
|
|
}
|
|
|
|
public async Task<List<CustomerSubItemDto>> GetExtraFeesAsync(Guid subscriptionId, HttpContext httpContext)
|
|
{
|
|
await LoadAuthorizedSubscription(subscriptionId, httpContext);
|
|
|
|
return (await _customerSubscriptionRepository.GetSubItemsAsync(
|
|
subscriptionId, activeOnly: true, newestFirst: true))
|
|
.Select(ToSubItemDto)
|
|
.ToList();
|
|
}
|
|
|
|
public async Task<object> AddExtraFeeAsync(Guid subscriptionId, AddExtraFeeRequest request, HttpContext httpContext)
|
|
{
|
|
await LoadAuthorizedSubscription(subscriptionId, httpContext);
|
|
|
|
if (string.IsNullOrWhiteSpace(request.Name))
|
|
throw new ArgumentException("A name is required for the extra fee.");
|
|
if (request.Amount <= 0)
|
|
throw new ArgumentException("Amount must be greater than zero.");
|
|
if (request.Frequency != "OneTime" && request.Frequency != "Recurring")
|
|
throw new ArgumentException("Frequency must be 'OneTime' or 'Recurring'.");
|
|
|
|
await _uow.BeginAsync();
|
|
try
|
|
{
|
|
var subItem = new CustomerSubItem
|
|
{
|
|
CustomerSubItemId = Guid.NewGuid(),
|
|
FKCustomerSubscriptionId = subscriptionId,
|
|
SubItemName = request.Name,
|
|
SubItemDescription = request.Description,
|
|
SubItemPrice = request.Amount,
|
|
Frequency = request.Frequency,
|
|
IsActive = true,
|
|
CreatedAt = DateTime.UtcNow
|
|
};
|
|
await _customerSubscriptionRepository.AddSubItemAsync(subItem);
|
|
|
|
// Charged immediately as its own invoice — the subscription's original
|
|
// invoice is idempotent and won't retroactively pick up items added later.
|
|
var invoice = await _customerInvoiceRepository.CreateSupplementalInvoiceAsync(
|
|
subscriptionId, request.Name, request.Description, request.Amount);
|
|
|
|
await _uow.CommitAsync();
|
|
|
|
return new
|
|
{
|
|
customerSubItemId = subItem.CustomerSubItemId,
|
|
customerInvoiceId = invoice.CustomerInvoiceId,
|
|
amount = invoice.Amount,
|
|
frequency = subItem.Frequency
|
|
};
|
|
}
|
|
catch
|
|
{
|
|
await _uow.RollbackAsync();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private async Task<Models.CustomerSubscription> LoadAuthorizedSubscription(Guid subscriptionId, HttpContext httpContext)
|
|
{
|
|
var subscription = await _customerSubscriptionRepository.GetCustomerSubscriptionById(subscriptionId);
|
|
if (subscription == null)
|
|
throw new KeyNotFoundException("Subscription not found");
|
|
|
|
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
|
|
Guid clientId = await _userRoleHelper.GetClientId(userId);
|
|
if (subscription.CustomerPlan == null || subscription.CustomerPlan.FKClientId != clientId)
|
|
throw new UnauthorizedAccessException("This subscription does not belong to your gym");
|
|
|
|
return subscription;
|
|
}
|
|
|
|
private async Task<SubscriptionSummaryDto> BuildSummaryAsync(Models.CustomerSubscription subscription)
|
|
{
|
|
var plan = subscription.CustomerPlan;
|
|
|
|
var invoice = await _customerSubscriptionRepository
|
|
.GetLatestInvoiceAsync(subscription.SubscriptionId);
|
|
|
|
decimal invoiceAmount = invoice?.Amount ?? plan?.PlanPrice ?? 0m;
|
|
decimal paid = 0m;
|
|
if (invoice != null)
|
|
{
|
|
paid = await _customerSubscriptionRepository
|
|
.GetApprovedPaymentTotalAsync(invoice.CustomerInvoiceId);
|
|
}
|
|
|
|
var members = (subscription.SubscriptionMembers ?? new List<SubscriptionMember>())
|
|
.Where(m => m.IsActive)
|
|
.OrderByDescending(m => m.IsPrimary)
|
|
.ThenBy(m => m.JoinedAt)
|
|
.Select(m => new SubscriptionMemberDto
|
|
{
|
|
SubscriptionMemberId = m.SubscriptionMemberId,
|
|
CustomerId = m.FKCustomerId,
|
|
CusName = m.TenantCustomerLink?.DisplayName,
|
|
Email = m.TenantCustomerLink?.EmailCached,
|
|
PrimaryPH = m.TenantCustomerLink?.PrimaryPhoneCached,
|
|
IsPrimary = m.IsPrimary,
|
|
JoinedAt = m.JoinedAt,
|
|
IsActive = m.IsActive,
|
|
IsPlanAssigned = m.IsPlanAssigned
|
|
})
|
|
.ToList();
|
|
|
|
var installments = (subscription.SubscriptionInstallments ?? new List<SubscriptionInstallment>())
|
|
.Where(i => i.IsActive)
|
|
.OrderBy(i => i.InstallmentNo)
|
|
.Select(i => new SubscriptionInstallmentDto
|
|
{
|
|
SubscriptionInstallmentId = i.SubscriptionInstallmentId,
|
|
InstallmentNo = i.InstallmentNo,
|
|
DueDate = i.DueDate,
|
|
Amount = i.Amount,
|
|
PaidAmount = i.PaidAmount,
|
|
Status = i.Status
|
|
})
|
|
.ToList();
|
|
|
|
AutoChargeCardDto? autoChargeCard = null;
|
|
if (subscription.FKAutoChargeCardId.HasValue)
|
|
{
|
|
var card = await _customerSubscriptionRepository
|
|
.GetCardByIdAsync(subscription.FKAutoChargeCardId.Value);
|
|
if (card != null)
|
|
{
|
|
autoChargeCard = new AutoChargeCardDto
|
|
{
|
|
CardId = card.JobId,
|
|
CardLast4 = card.CardNumber,
|
|
CardType = card.CardType
|
|
};
|
|
}
|
|
}
|
|
|
|
return new SubscriptionSummaryDto
|
|
{
|
|
SubscriptionId = subscription.SubscriptionId,
|
|
CustomerPlanId = subscription.FKCustomerPlanId,
|
|
PlanName = plan?.PlanName,
|
|
PlanScope = plan?.PlanScope ?? "Individual",
|
|
MemberLimit = plan?.MemberLimit ?? 1,
|
|
DurationMode = plan?.DurationMode ?? "Rolling",
|
|
AllowSplitPayments = plan?.AllowSplitPayments ?? false,
|
|
PlanPrice = plan?.PlanPrice ?? 0m,
|
|
OnboardDate = subscription.OnboardDate,
|
|
ExpiryDate = subscription.ExpiryDate,
|
|
RenewalDate = subscription.RenewalDate,
|
|
IsActive = subscription.IsActive,
|
|
CustomerInvoiceId = invoice?.CustomerInvoiceId,
|
|
InvoiceAmount = invoiceAmount,
|
|
PaidAmount = paid,
|
|
Balance = invoiceAmount - paid,
|
|
PaymentStatus = paid >= invoiceAmount && invoiceAmount > 0
|
|
? "Paid"
|
|
: paid > 0 ? "Partial" : "Pending",
|
|
Members = members,
|
|
Installments = installments,
|
|
AutoChargeEnabled = subscription.AutoChargeEnabled,
|
|
AutoChargeCard = autoChargeCard
|
|
};
|
|
}
|
|
|
|
private static CustomerSubItemDto ToSubItemDto(CustomerSubItem item) => new()
|
|
{
|
|
CustomerSubItemId = item.CustomerSubItemId,
|
|
SubItemName = item.SubItemName,
|
|
SubItemDescription = item.SubItemDescription,
|
|
SubItemPrice = item.SubItemPrice,
|
|
Frequency = item.Frequency,
|
|
IsActive = item.IsActive,
|
|
CreatedAt = item.CreatedAt
|
|
};
|
|
}
|
|
}
|
|
|
|
// add function to add new sub-item to existing subscription, which can be used for add-on purchases
|