257 lines
12 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|