first commit
This commit is contained in:
@@ -0,0 +1,510 @@
|
||||
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.GetActiveSubscriptionByCustomerId(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 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).
|
||||
var hasOtherActivePlan = await _customerSubscriptionRepository.HasAnyActiveSubscriptionAsync(customerId);
|
||||
if (hasOtherActivePlan)
|
||||
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
|
||||
Reference in New Issue
Block a user