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