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

158 lines
8.4 KiB
C#

using Fitness_core_BE.Infra.Tenancy;
using Fitness_core_BE.Infra.UoW;
using Fitness_core_BE.Repos;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Services
{
public sealed record SubscriptionRenewalRunResult(
Guid ClientId, int Candidates, int Renewed, int Failed, IReadOnlyList<string> Notes);
// The per-schema renewal algorithm, extracted from SubscriptionRenewalJob's daily loop so
// the admin "trigger now" endpoint can call the exact same code path instead of duplicating it.
//
// Renewal-approval model: the daily sweep never touches the CustomerSubscription itself
// (IsActive/RenewalDate/members/installments) -- it only computes whether a new invoice is
// due and creates it as unapproved (IsActive=false). Advancing RenewalDate, and everything
// else that used to happen automatically (deactivate old subscription, create a new one),
// now only happens when staff approve/change-plan/reject via SubscriptionRenewalApprovalService.
public class SubscriptionRenewalRunner(ILogger<SubscriptionRenewalRunner> logger)
{
private readonly ILogger<SubscriptionRenewalRunner> _logger = logger;
public async Task<SubscriptionRenewalRunResult> RunForSchemaAsync(
TenantSchemaContext schemaCtx, DateTime now, CancellationToken ct = default)
{
var tenant = schemaCtx.Tenant;
var central = schemaCtx.Central;
var today = now.Date;
// DurationMode == "Rolling" is a hard requirement, not a preference: FixedRange
// plans are fixed-date, non-renewing by design. RenewalDate (not ExpiryDate) drives
// eligibility -- ExpiryDate is frozen at creation and never advances. The "no
// already-pending invoice" exclusion is what makes this idempotent: once a renewal
// invoice exists unapproved, the subscription is skipped on every subsequent run
// until an approval action (approve/change-plan/reject) resolves it.
var candidates = await tenant.CustomerSubscriptions
.Include(s => s.CustomerPlan)
.Where(s => s.IsActive
&& s.RenewalDate != null && s.RenewalDate < today.AddDays(1)
&& s.CustomerPlan.DurationMode == "Rolling"
&& s.CustomerPlan.IsActive
&& !tenant.CustomerInvoices.Any(i =>
i.FKCustomerSubscriptionId == s.SubscriptionId
&& i.IsActive == false
&& i.Status != "Cancelled"))
.ToListAsync(ct);
_logger.LogInformation(
"SubscriptionRenewalJob: client {ClientId} -- {Candidates} subscription(s) due for renewal.",
schemaCtx.ClientId, candidates.Count);
var notes = new List<string>();
int renewed = 0, failed = 0;
var uow = new EFUnitOfWork(central, tenant);
var subscriptionRepo = new CustomerSubscriptionRepository(tenant, central);
var invoiceRepo = new CustomerInvoiceRepository(tenant);
foreach (var oldSubscription in candidates)
{
try
{
await RenewOneAsync(oldSubscription, tenant, uow, subscriptionRepo, invoiceRepo, _logger, ct);
renewed++;
}
catch (Exception ex)
{
await uow.RollbackAsync(ct);
// RollbackAsync only rolls back the DB transaction -- it does not clear EF's
// change tracker. tenant/central are reused across every candidate in this
// loop, so a failed candidate's tracked-but-unsaved mutations (e.g. the
// "RolledOver" status flip) would otherwise still be flushed by the *next*
// candidate's SaveChanges/CommitAsync.
tenant.ChangeTracker.Clear();
central.ChangeTracker.Clear();
failed++;
var note = $"Renewal failed for subscription {oldSubscription.SubscriptionId} (customer {oldSubscription.FKCustomerId}): {ex.Message}";
notes.Add(note);
_logger.LogError(ex, "SubscriptionRenewalJob: {Note}", note);
}
}
_logger.LogInformation(
"SubscriptionRenewalJob: client {ClientId} done -- {Renewed} renewal invoice(s) created, {Failed} failed.",
schemaCtx.ClientId, renewed, failed);
return new SubscriptionRenewalRunResult(schemaCtx.ClientId, candidates.Count, renewed, failed, notes);
}
private static async Task RenewOneAsync(
Fitness_core_BE.Models.CustomerSubscription oldSubscription,
TenantDbContext tenant,
EFUnitOfWork uow,
CustomerSubscriptionRepository subscriptionRepo,
CustomerInvoiceRepository invoiceRepo,
ILogger logger,
CancellationToken ct)
{
await uow.BeginAsync(ct);
// By construction this is still the real, currently-collectible invoice: the
// candidate query above already excludes subscriptions with a pending-approval
// invoice, and nothing else sets CustomerInvoice.IsActive=false.
var oldInvoice = await subscriptionRepo.GetLatestInvoiceAsync(oldSubscription.SubscriptionId, ct);
if (oldInvoice == null)
{
throw new InvalidOperationException(
$"Subscription {oldSubscription.SubscriptionId} has no invoice to renew from.");
}
decimal carriedOverBalance;
switch (oldInvoice.Status)
{
case "Paid":
carriedOverBalance = 0m;
logger.LogInformation(
"SubscriptionRenewalJob: subscription {SubscriptionId} -- prior invoice {InvoiceId} was Paid, no balance carried forward.",
oldSubscription.SubscriptionId, oldInvoice.CustomerInvoiceId);
break;
case "Pending":
case "Partial":
var approvedPaid = await subscriptionRepo.GetApprovedPaymentTotalAsync(oldInvoice.CustomerInvoiceId, ct);
carriedOverBalance = Math.Max(0m, oldInvoice.Amount - approvedPaid);
// GetLatestInvoiceAsync reads AsNoTracking -- re-fetch tracked to mutate it.
var trackedOldInvoice = await tenant.CustomerInvoices.FindAsync(
new object[] { oldInvoice.CustomerInvoiceId }, ct);
trackedOldInvoice!.Status = "RolledOver";
logger.LogInformation(
"SubscriptionRenewalJob: subscription {SubscriptionId} -- prior invoice {InvoiceId} was {OldStatus}, rolling over unpaid balance {CarriedOverBalance} and marking it RolledOver.",
oldSubscription.SubscriptionId, oldInvoice.CustomerInvoiceId, oldInvoice.Status, carriedOverBalance);
break;
default:
// "Failed"/"Refunded" (or a stray "Cancelled"/"RolledOver" the candidate
// query's own invoice-state shouldn't produce) -- don't guess how to carry
// these forward; surfaces as a PartialFailure note for manual review.
throw new InvalidOperationException(
$"Subscription {oldSubscription.SubscriptionId}'s current invoice has unexpected status '{oldInvoice.Status}'.");
}
// Renewal invoices skip the onboarding fee (one-time, not a per-cycle charge) and
// start unapproved (IsActive=false) -- staff must approve/change-plan/reject before
// it counts. The subscription itself (IsActive, RenewalDate, members, installments)
// is untouched here; all of that is deferred to the approval action.
var newInvoice = await invoiceRepo.CreateRenewalInvoiceAsync(oldSubscription.SubscriptionId, carriedOverBalance, ct);
await uow.CommitAsync(ct);
logger.LogInformation(
"SubscriptionRenewalJob: subscription {SubscriptionId} -- created unapproved renewal invoice {InvoiceId} (amount {Amount}), awaiting staff approval.",
oldSubscription.SubscriptionId, newInvoice.CustomerInvoiceId, newInvoice.Amount);
}
}
}