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.CustomerPlan; using System.Text.Json; using Fitness_core_BE.Helpers; namespace Fitness_core_BE.Services.CustomerPlanService { public class CustomerPlanService( ICustomerPlanRepository customerPlanRepository, IFacilityRepository facilityRepository, IUnitOfWork uow, UserRoleHelper userRoleHelper) : ICustomerPlanService { private const string ScopeIndividual = "Individual"; private const string ScopeGroup = "Group"; private const string ModeRolling = "Rolling"; private const string ModeFixedRange = "FixedRange"; private const string RolloverNone = "NoRollover"; private const string RolloverGrace = "GracePeriod"; private const string BillingPrepaid = "Prepaid"; private const string BillingPostpaid = "Postpaid"; private readonly ICustomerPlanRepository _customerPlanRepository = customerPlanRepository; private readonly IFacilityRepository _facilityRepository = facilityRepository; private readonly IUnitOfWork _uow = uow; private readonly UserRoleHelper _userRoleHelper = userRoleHelper; public async Task GetAllCustomerPlans(object payload, HttpContext httpContext) { var data = DeserializePayload.Deserialize(payload); bool includeInactive = data != null && data.ContainsKey("includeInactive") && data["includeInactive"].GetBoolean(); Guid userId = DataExtractor.GetUserIdFromContext(httpContext); Guid contextClientId = await _userRoleHelper.GetClientId(userId); Guid? requestedClientId = null; if (data != null && data.ContainsKey("FKClientId") && data["FKClientId"].ValueKind != JsonValueKind.Null) { requestedClientId = Guid.Parse(data["FKClientId"].GetString()!); } var clientId = requestedClientId ?? contextClientId; var plans = await _customerPlanRepository.GetAllCustomerPlans(includeInactive, clientId); var placeIdsByPlan = await _facilityRepository.GetFacilityIdsByPlanIdsAsync(plans.Select(p => p.CustomerPlanId).ToList()); var allPlaceIds = placeIdsByPlan.Values.SelectMany(ids => ids).Distinct().ToList(); var placeNames = await _facilityRepository.GetFacilityNamesByIdsAsync(allPlaceIds); return plans.Select(p => { var placeIds = placeIdsByPlan.GetValueOrDefault(p.CustomerPlanId) ?? new List(); return MapToDto(p, p.Client?.ClientName, placeIds, placeNames); }).ToList(); } public async Task GetCustomerPlanById(object payload, HttpContext httpContext) { var data = DeserializePayload.Deserialize(payload); if (!data!.ContainsKey("customerPlanId")) { throw new KeyNotFoundException("customerPlanId is required"); } var customerPlanId = Guid.Parse(data["customerPlanId"].GetString()!); var plan = await _customerPlanRepository.GetCustomerPlanById(customerPlanId); if (plan == null) { throw new KeyNotFoundException("Customer Plan not found"); } return await MapToDtoWithPlacesAsync(plan, plan.Client?.ClientName); } public async Task CreateCustomerPlan(object payload, HttpContext httpContext) { await _uow.BeginAsync(); try { var data = DeserializePayload.Deserialize(payload); if (!data!.ContainsKey("planName")) { throw new KeyNotFoundException("planName is required"); } if (!data.ContainsKey("planPrice")) { throw new KeyNotFoundException("planPrice is required"); } Guid userId = DataExtractor.GetUserIdFromContext(httpContext); Guid clientId = await _userRoleHelper.GetClientId(userId); var customerPlan = new Models.CustomerPlan { FKClientId = clientId, PlanName = data["planName"].GetString(), PlanDescription = data.ContainsKey("planDescription") ? data["planDescription"].GetString() : null, PlanPrice = data["planPrice"].GetDecimal(), HasOnboardingFee = data.ContainsKey("hasOnboardingFee") && data["hasOnboardingFee"].GetBoolean(), OnboardingFeeAmount = data.ContainsKey("onboardingFeeAmount") ? data["onboardingFeeAmount"].GetDecimal() : 0m, PlanScope = data.ContainsKey("planScope") ? data["planScope"].GetString() ?? ScopeIndividual : ScopeIndividual, MemberLimit = data.ContainsKey("memberLimit") ? data["memberLimit"].GetInt32() : 1, DurationMode = data.ContainsKey("durationMode") ? data["durationMode"].GetString() ?? ModeRolling : ModeRolling, DurationInDays = data.ContainsKey("durationInDays") ? data["durationInDays"].GetInt32() : 0, ValidFrom = DataExtractor.GetOptionalDateTime(data, "validFrom"), ValidTo = DataExtractor.GetOptionalDateTime(data, "validTo"), AllowSplitPayments = data.ContainsKey("allowSplitPayments") && data["allowSplitPayments"].GetBoolean(), InstallmentCount = data.ContainsKey("installmentCount") ? data["installmentCount"].GetInt32() : 0, SessionsPerCycle = data.ContainsKey("sessionsPerCycle") && data["sessionsPerCycle"].ValueKind != JsonValueKind.Null ? data["sessionsPerCycle"].GetInt32() : null, SessionRolloverPolicy = data.ContainsKey("sessionRolloverPolicy") ? data["sessionRolloverPolicy"].GetString() ?? RolloverNone : RolloverNone, RolloverGraceDays = data.ContainsKey("rolloverGraceDays") ? data["rolloverGraceDays"].GetInt32() : 0, BillingTiming = data.ContainsKey("billingTiming") ? data["billingTiming"].GetString() ?? BillingPrepaid : BillingPrepaid }; NormalizeAndValidate(customerPlan); var result = await _customerPlanRepository.CreateCustomerPlan(customerPlan); await _uow.SaveChangesAsync(); var placeIds = DataExtractor.GetOptionalGuidList(data, "placeIds"); if (placeIds != null) await _facilityRepository.SetPlanFacilitiesAsync(result.CustomerPlanId, placeIds); await _uow.CommitAsync(); // Fetch the created plan with client details var createdPlan = await _customerPlanRepository.GetCustomerPlanById(result.CustomerPlanId); return await MapToDtoWithPlacesAsync(result, createdPlan?.Client?.ClientName); } catch { await _uow.RollbackAsync(); throw; } } public async Task UpdateCustomerPlan(object payload, HttpContext httpContext) { await _uow.BeginAsync(); try { var data = DeserializePayload.Deserialize(payload); if (!data!.ContainsKey("customerPlanId")) { throw new KeyNotFoundException("customerPlanId is required"); } var customerPlanId = Guid.Parse(data["customerPlanId"].GetString()!); var existingPlan = await _customerPlanRepository.GetCustomerPlanById(customerPlanId); if (existingPlan == null) { throw new KeyNotFoundException("Customer Plan not found"); } // Merge: start from existing values, override only the provided keys, // then re-validate the combined configuration. if (data.ContainsKey("planName")) existingPlan.PlanName = data["planName"].GetString(); if (data.ContainsKey("planDescription")) existingPlan.PlanDescription = data["planDescription"].GetString(); if (data.ContainsKey("planPrice")) existingPlan.PlanPrice = data["planPrice"].GetDecimal(); if (data.ContainsKey("hasOnboardingFee")) existingPlan.HasOnboardingFee = data["hasOnboardingFee"].GetBoolean(); if (data.ContainsKey("onboardingFeeAmount")) existingPlan.OnboardingFeeAmount = data["onboardingFeeAmount"].GetDecimal(); if (data.ContainsKey("planScope")) existingPlan.PlanScope = data["planScope"].GetString() ?? existingPlan.PlanScope; if (data.ContainsKey("memberLimit")) existingPlan.MemberLimit = data["memberLimit"].GetInt32(); if (data.ContainsKey("durationMode")) existingPlan.DurationMode = data["durationMode"].GetString() ?? existingPlan.DurationMode; if (data.ContainsKey("durationInDays")) existingPlan.DurationInDays = data["durationInDays"].GetInt32(); if (data.ContainsKey("validFrom")) existingPlan.ValidFrom = DataExtractor.GetOptionalDateTime(data, "validFrom"); if (data.ContainsKey("validTo")) existingPlan.ValidTo = DataExtractor.GetOptionalDateTime(data, "validTo"); if (data.ContainsKey("allowSplitPayments")) existingPlan.AllowSplitPayments = data["allowSplitPayments"].GetBoolean(); if (data.ContainsKey("installmentCount")) existingPlan.InstallmentCount = data["installmentCount"].GetInt32(); if (data.ContainsKey("sessionsPerCycle")) existingPlan.SessionsPerCycle = data["sessionsPerCycle"].ValueKind == JsonValueKind.Null ? null : data["sessionsPerCycle"].GetInt32(); if (data.ContainsKey("sessionRolloverPolicy")) existingPlan.SessionRolloverPolicy = data["sessionRolloverPolicy"].GetString() ?? existingPlan.SessionRolloverPolicy; if (data.ContainsKey("rolloverGraceDays")) existingPlan.RolloverGraceDays = data["rolloverGraceDays"].GetInt32(); if (data.ContainsKey("billingTiming")) existingPlan.BillingTiming = data["billingTiming"].GetString() ?? existingPlan.BillingTiming; if (data.ContainsKey("isActive")) existingPlan.IsActive = data["isActive"].GetBoolean(); NormalizeAndValidate(existingPlan); var result = await _customerPlanRepository.UpdateCustomerPlan(existingPlan); var placeIds = DataExtractor.GetOptionalGuidList(data, "placeIds"); if (placeIds != null) await _facilityRepository.SetPlanFacilitiesAsync(result.CustomerPlanId, placeIds); await _uow.CommitAsync(); var updatedPlan = await _customerPlanRepository.GetCustomerPlanById(result.CustomerPlanId); return await MapToDtoWithPlacesAsync(result, updatedPlan?.Client?.ClientName); } catch { await _uow.RollbackAsync(); throw; } } public async Task SetCustomerPlanStatus(object payload, HttpContext httpContext) { await _uow.BeginAsync(); try { var data = DeserializePayload.Deserialize(payload); if (!data!.ContainsKey("customerPlanId")) { throw new KeyNotFoundException("customerPlanId is required"); } if (!data.ContainsKey("isActive")) { throw new KeyNotFoundException("isActive is required"); } var customerPlanId = Guid.Parse(data["customerPlanId"].GetString()!); var isActive = data["isActive"].GetBoolean(); Guid userId = DataExtractor.GetUserIdFromContext(httpContext); Guid clientId = await _userRoleHelper.GetClientId(userId); // Scoped to the caller's gym, so a plan id from another gym reads as // "not found" rather than being toggled. var plan = await _customerPlanRepository.SetCustomerPlanActive(customerPlanId, clientId, isActive); if (plan == null) { throw new KeyNotFoundException("Customer Plan not found"); } await _uow.CommitAsync(); return await MapToDtoWithPlacesAsync(plan, plan.Client?.ClientName); } catch { await _uow.RollbackAsync(); throw; } } public async Task DeleteCustomerPlan(object payload, HttpContext httpContext) { await _uow.BeginAsync(); try { var data = DeserializePayload.Deserialize(payload); if (!data!.ContainsKey("customerPlanId")) { throw new KeyNotFoundException("customerPlanId is required"); } var customerPlanId = Guid.Parse(data["customerPlanId"].GetString()!); // Check if hard delete is requested (default is soft delete) bool softDelete = !data.ContainsKey("hardDelete") || !data["hardDelete"].GetBoolean(); var result = await _customerPlanRepository.DeleteCustomerPlan(customerPlanId, softDelete); if (!result) { throw new KeyNotFoundException("Customer Plan not found"); } await _uow.CommitAsync(); return new { success = true, message = softDelete ? "Customer Plan deactivated successfully" : "Customer Plan deleted successfully", softDelete = softDelete }; } catch { await _uow.RollbackAsync(); throw; } } /// /// Enforces the cross-field rules of the plan configuration and fills /// derived defaults (member limit, installment count, cleared date range). /// Throws ArgumentException on invalid combinations. /// private static void NormalizeAndValidate(Models.CustomerPlan plan) { // Scope if (plan.PlanScope != ScopeIndividual && plan.PlanScope != ScopeGroup) throw new ArgumentException($"planScope must be \"{ScopeIndividual}\" or \"{ScopeGroup}\""); if (plan.PlanScope == ScopeGroup) { if (plan.MemberLimit < 2) throw new ArgumentException("memberLimit must be at least 2 for Group plans (couple = 2)"); } else { plan.MemberLimit = 1; } // Duration if (plan.DurationMode != ModeRolling && plan.DurationMode != ModeFixedRange) throw new ArgumentException($"durationMode must be \"{ModeRolling}\" or \"{ModeFixedRange}\""); int effectiveDays; if (plan.DurationMode == ModeRolling) { if (plan.DurationInDays < 1) throw new ArgumentException("durationInDays (>= 1) is required for Rolling plans"); plan.ValidFrom = null; plan.ValidTo = null; effectiveDays = plan.DurationInDays; } else { if (plan.ValidFrom == null || plan.ValidTo == null) throw new ArgumentException("validFrom and validTo are required for FixedRange plans"); if (plan.ValidTo <= plan.ValidFrom) throw new ArgumentException("validTo must be after validFrom"); plan.DurationInDays = 0; effectiveDays = (int)(plan.ValidTo.Value - plan.ValidFrom.Value).TotalDays; } if (plan.PlanPrice < 0) throw new ArgumentException("planPrice must be a positive value"); if (plan.OnboardingFeeAmount < 0) throw new ArgumentException("onboardingFeeAmount must be zero or greater"); if (!plan.HasOnboardingFee) plan.OnboardingFeeAmount = 0m; else if (plan.OnboardingFeeAmount <= 0) throw new ArgumentException("onboardingFeeAmount must be greater than zero when hasOnboardingFee is true"); // Split payments: mostly-equal installments (e.g. Rs. 6000 / 6 months -> 6 x Rs. 1000). if (plan.AllowSplitPayments) { if (plan.InstallmentCount < 2) { // Default: one installment per month of the plan's duration. plan.InstallmentCount = Math.Max(2, (int)Math.Round(effectiveDays / 30.0)); } } else { plan.InstallmentCount = 1; } // Session-count / rollover / billing timing (new SRS requirements). if (plan.SessionRolloverPolicy != RolloverNone && plan.SessionRolloverPolicy != RolloverGrace) throw new ArgumentException($"sessionRolloverPolicy must be \"{RolloverNone}\" or \"{RolloverGrace}\""); if (plan.SessionsPerCycle.HasValue) { if (plan.SessionsPerCycle.Value < 1) throw new ArgumentException("sessionsPerCycle must be at least 1 when set"); if (plan.SessionRolloverPolicy == RolloverGrace && plan.RolloverGraceDays < 1) throw new ArgumentException("rolloverGraceDays must be at least 1 when sessionRolloverPolicy is \"GracePeriod\""); } else { // No session allowance on this plan (unlimited-access, existing gym behavior) -- // rollover settings are meaningless without a per-cycle count to roll over. plan.SessionRolloverPolicy = RolloverNone; plan.RolloverGraceDays = 0; } if (plan.BillingTiming != BillingPrepaid && plan.BillingTiming != BillingPostpaid) throw new ArgumentException($"billingTiming must be \"{BillingPrepaid}\" or \"{BillingPostpaid}\""); } private async Task MapToDtoWithPlacesAsync(Models.CustomerPlan plan, string? clientName) { var placeIds = await _facilityRepository.GetFacilityIdsForPlanAsync(plan.CustomerPlanId); var placeNames = await _facilityRepository.GetFacilityNamesByIdsAsync(placeIds); return MapToDto(plan, clientName, placeIds, placeNames); } private static CustomerPlanDto MapToDto( Models.CustomerPlan plan, string? clientName, List placeIds, Dictionary placeNames) => new() { CustomerPlanId = plan.CustomerPlanId, FKClientId = plan.FKClientId, ClientName = clientName, PlanName = plan.PlanName, PlanDescription = plan.PlanDescription, PlanPrice = plan.PlanPrice, HasOnboardingFee = plan.HasOnboardingFee, OnboardingFeeAmount = plan.OnboardingFeeAmount, DurationInDays = plan.DurationInDays, PlanScope = plan.PlanScope, MemberLimit = plan.MemberLimit, DurationMode = plan.DurationMode, ValidFrom = plan.ValidFrom, ValidTo = plan.ValidTo, AllowSplitPayments = plan.AllowSplitPayments, InstallmentCount = plan.InstallmentCount, SessionsPerCycle = plan.SessionsPerCycle, SessionRolloverPolicy = plan.SessionRolloverPolicy, RolloverGraceDays = plan.RolloverGraceDays, BillingTiming = plan.BillingTiming, IsActive = plan.IsActive, CreatedAt = plan.CreatedAt, PlaceIds = placeIds, PlaceNames = placeIds.Select(id => placeNames.GetValueOrDefault(id, "")).Where(n => n != "").ToList() }; } }