441 lines
20 KiB
C#
441 lines
20 KiB
C#
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 : 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;
|
|
private readonly IPlaceRepo _placeRepo;
|
|
private readonly IUnitOfWork _uow;
|
|
private readonly UserRoleHelper _userRoleHelper;
|
|
|
|
public CustomerPlanService(
|
|
ICustomerPlanRepository customerPlanRepository,
|
|
IPlaceRepo placeRepo,
|
|
IUnitOfWork uow,
|
|
UserRoleHelper userRoleHelper)
|
|
{
|
|
_customerPlanRepository = customerPlanRepository;
|
|
_placeRepo = placeRepo;
|
|
_uow = uow;
|
|
_userRoleHelper = userRoleHelper;
|
|
}
|
|
|
|
public async Task<object> 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 _placeRepo.GetPlaceIdsByPlanIdsAsync(plans.Select(p => p.CustomerPlanId).ToList());
|
|
var allPlaceIds = placeIdsByPlan.Values.SelectMany(ids => ids).Distinct().ToList();
|
|
var placeNames = await _placeRepo.GetPlaceNamesByIdsAsync(allPlaceIds);
|
|
|
|
return plans.Select(p =>
|
|
{
|
|
var placeIds = placeIdsByPlan.GetValueOrDefault(p.CustomerPlanId) ?? new List<Guid>();
|
|
return MapToDto(p, p.Client?.ClientName, placeIds, placeNames);
|
|
}).ToList();
|
|
}
|
|
|
|
public async Task<object> 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<object> 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 _placeRepo.SetPlanPlacesAsync(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<object> 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 _placeRepo.SetPlanPlacesAsync(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<object> 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<object> 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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<CustomerPlanDto> MapToDtoWithPlacesAsync(Models.CustomerPlan plan, string? clientName)
|
|
{
|
|
var placeIds = await _placeRepo.GetPlaceIdsForPlanAsync(plan.CustomerPlanId);
|
|
var placeNames = await _placeRepo.GetPlaceNamesByIdsAsync(placeIds);
|
|
return MapToDto(plan, clientName, placeIds, placeNames);
|
|
}
|
|
|
|
private static CustomerPlanDto MapToDto(
|
|
Models.CustomerPlan plan,
|
|
string? clientName,
|
|
List<Guid> placeIds,
|
|
Dictionary<Guid, string> 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()
|
|
};
|
|
}
|
|
}
|