Files
Core_Be/Services/SubscriptionRenewalApprovalService.cs
Dhananjaya99 bffc9b1686 first commit
2026-09-28 11:44:26 +05:30

257 lines
12 KiB
C#

using Fitness_core_BE.Domain;
using Fitness_core_BE.Exceptions;
using Fitness_core_BE.Helpers;
using Fitness_core_BE.Infra.UoW;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models;
using Microsoft.AspNetCore.Http;
namespace Fitness_core_BE.Services
{
// Staff-facing approval workflow for the unapproved (IsActive=false) invoices the daily
// SubscriptionRenewalJob sweep creates -- approve/change-plan/reject are the only things
// that ever advance CustomerSubscription.RenewalDate or replace a subscription's plan; the
// daily sweep itself only ever creates unapproved invoices.
public class SubscriptionRenewalApprovalService : ISubscriptionRenewalApprovalService
{
private readonly ICustomerInvoiceRepository _invoiceRepository;
private readonly ICustomerSubscriptionRepository _subscriptionRepository;
private readonly IClientRepository _clientRepository;
private readonly IUnitOfWork _uow;
private readonly UserRoleHelper _userRoleHelper;
private readonly ILogger<SubscriptionRenewalApprovalService> _logger;
public SubscriptionRenewalApprovalService(
ICustomerInvoiceRepository invoiceRepository,
ICustomerSubscriptionRepository subscriptionRepository,
IClientRepository clientRepository,
IUnitOfWork uow,
UserRoleHelper userRoleHelper,
ILogger<SubscriptionRenewalApprovalService> logger)
{
_invoiceRepository = invoiceRepository;
_subscriptionRepository = subscriptionRepository;
_clientRepository = clientRepository;
_uow = uow;
_userRoleHelper = userRoleHelper;
_logger = logger;
}
public async Task<object> GetUnapprovedInvoicesAsync(HttpContext httpContext)
{
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
var invoices = await _invoiceRepository.GetUnapprovedInvoicesByClientIdAsync(clientId);
// How much of `amount` is this cycle's own charge vs. debt carried over from a prior
// Pending/Partial invoice -- the approvals list shows the total already, but staff
// reviewing a renewal need to see the breakdown before approving it.
var carriedForward = await _invoiceRepository.GetCarriedForwardAmountsAsync(
invoices.Select(i => i.CustomerInvoiceId).ToList());
return invoices.Select(i =>
{
carriedForward.TryGetValue(i.CustomerInvoiceId, out var carriedForwardAmount);
return new
{
customerInvoiceId = i.CustomerInvoiceId,
fkCustomerSubscriptionId = i.FKCustomerSubscriptionId,
amount = i.Amount,
carriedForwardAmount,
status = i.Status,
customerInvoiceDate = i.CustomerInvoiceDate,
customerName = i.CustomerSubscription?.TenantCustomerLink?.DisplayName,
planName = i.CustomerSubscription?.CustomerPlan?.PlanName,
planPrice = i.CustomerSubscription?.CustomerPlan?.PlanPrice,
};
}).ToList();
}
public async Task<object> ApproveAsync(Guid customerInvoiceId, HttpContext httpContext)
{
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
await _uow.BeginAsync();
try
{
var invoice = await LoadUnapprovedInvoiceForClientAsync(customerInvoiceId, clientId);
var subscription = invoice.CustomerSubscription;
var plan = subscription.CustomerPlan
?? throw new BadRequestException("This subscription's plan could not be found.");
var now = DateTime.UtcNow;
// Installments are keyed by subscription, not by invoice/cycle -- the prior
// cycle's rows must be removed before this cycle's schedule is added (see
// DeactivatePriorInstallmentsAsync for why this is a hard delete).
await _subscriptionRepository.DeactivatePriorInstallmentsAsync(subscription.SubscriptionId);
var installments = CustomerSubscriptionFactory.BuildInstallmentSchedule(
subscription, plan, now, anchorDate: now);
await _subscriptionRepository.AddInstallmentsAsync(installments);
invoice.IsActive = true;
// Advance from the OLD RenewalDate, not from "now" -- a late approval shouldn't
// shift the cadence of future cycles.
var newRenewalDate = (subscription.RenewalDate ?? now).AddDays(plan.DurationInDays);
await _subscriptionRepository.AdvanceRenewalDateAsync(subscription.SubscriptionId, newRenewalDate);
// Session-count cycle reset (SessionsPerCycle plans only -- unlimited-access
// plans have nothing to reset). Whatever was left unused this ending cycle either
// rolls into the new one (GracePeriod, until RolloverGraceDays elapses) or is
// forfeited (NoRollover) -- see the fields' own comments on CustomerPlan/
// CustomerSubscription for the full reasoning.
if (plan.SessionsPerCycle.HasValue)
{
var allowanceThisCycle = plan.SessionsPerCycle.Value + subscription.SessionsCarriedOver;
var unusedThisCycle = Math.Max(0, allowanceThisCycle - subscription.SessionsUsedThisCycle);
if (plan.SessionRolloverPolicy == "GracePeriod" && unusedThisCycle > 0)
{
subscription.SessionsCarriedOver = unusedThisCycle;
subscription.SessionsCarriedOverExpiresAt = now.AddDays(plan.RolloverGraceDays);
}
else
{
subscription.SessionsCarriedOver = 0;
subscription.SessionsCarriedOverExpiresAt = null;
}
subscription.SessionsUsedThisCycle = 0;
}
await _uow.SaveChangesAsync();
await _uow.CommitAsync();
_logger.LogInformation(
"SubscriptionRenewalApproval: invoice {InvoiceId} approved for subscription {SubscriptionId}; RenewalDate advanced to {RenewalDate:u}.",
customerInvoiceId, subscription.SubscriptionId, newRenewalDate);
return new
{
success = true,
message = "Renewal approved",
customerInvoiceId,
subscriptionId = subscription.SubscriptionId,
renewalDate = newRenewalDate
};
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> ChangePlanAsync(Guid customerInvoiceId, Guid newCustomerPlanId, HttpContext httpContext)
{
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
await _uow.BeginAsync();
try
{
var invoice = await LoadUnapprovedInvoiceForClientAsync(customerInvoiceId, clientId);
var oldSubscription = invoice.CustomerSubscription;
var newPlan = await _clientRepository.GetCustomerPlanEntityForClientAsync(newCustomerPlanId, clientId)
?? throw new NotFoundException("Membership plan not found.");
if (newPlan.PlanScope != "Individual")
throw new BadRequestException("Change Membership only supports Individual-scope plans.");
var oldMembership = await _clientRepository.GetActiveMembershipForCustomerAsync(oldSubscription.FKCustomerId);
// Sibling namespace Fitness_core_BE.Services.CustomerSubscription
// (CustomerSubscriptionService.cs) outranks a bare `CustomerSubscription` here.
var newSubscription = new Fitness_core_BE.Models.CustomerSubscription
{
FKCustomerPlanId = newCustomerPlanId,
FKCustomerId = oldSubscription.FKCustomerId,
OnboardDate = DateTime.UtcNow,
IsActive = true
};
var initialSubItem = new CustomerSubItem
{
SubItemName = "Membership change (renewal approval)",
IsActive = true
};
var aggregate = CustomerSubscriptionFactory.Create(newSubscription, newPlan, initialSubItem);
await _clientRepository.ReplaceCustomerSubscriptionAsync(oldMembership, aggregate);
// RepointAndRecalculateInvoiceAsync re-queries the new subscription by id, so it
// must be persisted first.
await _uow.SaveChangesAsync();
await _invoiceRepository.RepointAndRecalculateInvoiceAsync(customerInvoiceId, newSubscription.SubscriptionId);
await _uow.SaveChangesAsync();
await _uow.CommitAsync();
_logger.LogInformation(
"SubscriptionRenewalApproval: invoice {InvoiceId} approved via plan change -- subscription {OldSubscriptionId} replaced by {NewSubscriptionId} on plan {NewPlanId}.",
customerInvoiceId, oldSubscription.SubscriptionId, newSubscription.SubscriptionId, newCustomerPlanId);
return new
{
success = true,
message = "Plan changed and renewal approved",
customerInvoiceId,
subscriptionId = newSubscription.SubscriptionId
};
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> RejectAsync(Guid customerInvoiceId, HttpContext httpContext)
{
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
await _uow.BeginAsync();
try
{
var invoice = await LoadUnapprovedInvoiceForClientAsync(customerInvoiceId, clientId);
// Already IsActive=false (that's what put it in the approval queue) -- Status
// also needs a terminal value, otherwise it still reads as an outstanding invoice
// everywhere that isn't IsActive-filtered (e.g. GetCustomerInvoicesByClientId).
invoice.Status = "Cancelled";
invoice.IsActive = false;
await _clientRepository.DeactivateSubscriptionAsync(invoice.FKCustomerSubscriptionId);
await _uow.SaveChangesAsync();
await _uow.CommitAsync();
_logger.LogInformation(
"SubscriptionRenewalApproval: invoice {InvoiceId} rejected; subscription {SubscriptionId} deactivated.",
customerInvoiceId, invoice.FKCustomerSubscriptionId);
return new { success = true, message = "Renewal rejected", customerInvoiceId };
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
// Sibling namespace Fitness_core_BE.Services.CustomerInvoice (CustomerInvoiceService.cs)
// outranks a bare `CustomerInvoice` here, so the model type needs its full name.
private async Task<Fitness_core_BE.Models.CustomerInvoice> LoadUnapprovedInvoiceForClientAsync(Guid customerInvoiceId, Guid clientId)
{
var invoice = await _invoiceRepository.GetCustomerInvoiceById(customerInvoiceId)
?? throw new NotFoundException("Invoice not found.");
if (invoice.FKClientId != clientId)
throw new ForbiddenException("This invoice does not belong to your gym.");
if (invoice.IsActive)
throw new BadRequestException("This invoice has already been approved or is not awaiting approval.");
return invoice;
}
}
}