158 lines
8.4 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|