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 _logger; public SubscriptionRenewalApprovalService( ICustomerInvoiceRepository invoiceRepository, ICustomerSubscriptionRepository subscriptionRepository, IClientRepository clientRepository, IUnitOfWork uow, UserRoleHelper userRoleHelper, ILogger logger) { _invoiceRepository = invoiceRepository; _subscriptionRepository = subscriptionRepository; _clientRepository = clientRepository; _uow = uow; _userRoleHelper = userRoleHelper; _logger = logger; } public async Task 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 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 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 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 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; } } }