first commit
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user