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 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 logger) { private readonly ILogger _logger = logger; public async Task 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(); 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); } } }