Fix subscription expiry logic and HTML entities in page titles
Subscription expiry (SubscriptionExpiryBackgroundService): - Trials with no grace period now go directly Active -> Expired instead of briefly entering GracePeriod for a day, which was causing repeated 'Grace Period Started' admin notification emails - Remove redundant isTrial variable (query already filters to non-Stripe companies, so all processed companies are trials by definition) - Save per-company inside the loop so a single SaveChangesAsync failure no longer discards all other companies' status changes and notification log entries (which was the other cause of repeated emails) HTML entities in page titles (33 views): - Replace – / — with plain ' - ' in ViewData["Title"] C# strings; Razor HTML-encodes these when rendering @ViewData["Title"], causing browsers to display the literal text '–' instead of a dash Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -67,9 +67,9 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a DI scope, queries non-Stripe-managed companies with active or grace-period subscriptions,
|
||||
/// and calls <see cref="ProcessCompanyAsync"/> for each. A single <c>SaveChangesAsync</c> at the
|
||||
/// end batches all status mutations into one round-trip. Errors are caught to keep the loop alive.
|
||||
/// Opens a DI scope, queries non-Stripe-managed (trial) companies with active or grace-period
|
||||
/// subscriptions, and calls <see cref="ProcessCompanyAsync"/> for each. Each company is saved
|
||||
/// individually so a single failure does not prevent other companies from being updated.
|
||||
/// </summary>
|
||||
private async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
@@ -103,15 +103,27 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
|
||||
|
||||
_logger.LogDebug("Found {Count} companies to evaluate.", companies.Count);
|
||||
|
||||
// All companies reaching this point have no StripeSubscriptionId — they are trials.
|
||||
// Paid subscribers are managed by Stripe and filtered out above.
|
||||
var effectiveGraceDays = gracePeriodAppliesToTrials ? gracePeriodDays : 0;
|
||||
|
||||
foreach (var company in companies)
|
||||
{
|
||||
if (ct.IsCancellationRequested) break;
|
||||
var isTrial = string.IsNullOrEmpty(company.StripeSubscriptionId);
|
||||
var effectiveGraceDays = isTrial && !gracePeriodAppliesToTrials ? 0 : gracePeriodDays;
|
||||
await ProcessCompanyAsync(db, emailService, adminNotification, company, today, effectiveGraceDays, ct);
|
||||
try
|
||||
{
|
||||
await ProcessCompanyAsync(db, emailService, adminNotification, company, today, effectiveGraceDays, ct);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Failed to process subscription expiry for company {Id} ({Name}). Status change was not persisted.",
|
||||
company.Id, company.CompanyName);
|
||||
// Clear EF tracked changes so bad state does not bleed into the next company.
|
||||
db.ChangeTracker.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -121,10 +133,15 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates a single company and performs any required status transitions or reminder sends.
|
||||
/// Transition logic: Active past end date → GracePeriod; GracePeriod past grace deadline → Expired + deactivated.
|
||||
/// Transition logic:
|
||||
/// <list type="bullet">
|
||||
/// <item>Active past end date, grace days = 0 → Expired + deactivated immediately (trials).</item>
|
||||
/// <item>Active past end date, grace days > 0 → GracePeriod + grace-period email.</item>
|
||||
/// <item>GracePeriod past grace deadline → Expired + deactivated.</item>
|
||||
/// </list>
|
||||
/// Reminder emails at <see cref="ReminderDays"/> offsets are sent only while the company is still Active.
|
||||
/// Platform admin is notified asynchronously (fire-and-forget) for both grace period start and full expiry
|
||||
/// so that operator action can be taken without delaying the main processing loop.
|
||||
/// Platform admin is notified asynchronously (fire-and-forget) so that operator action can be taken
|
||||
/// without delaying the main processing loop.
|
||||
/// </summary>
|
||||
private async Task ProcessCompanyAsync(
|
||||
ApplicationDbContext db,
|
||||
@@ -153,35 +170,55 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
|
||||
await WriteAuditLogAsync(db, company,
|
||||
$"Auto-expired: grace period ended {gracePeriodDays} days after subscription end {endDate:d}.");
|
||||
|
||||
// Notify platform admin
|
||||
_ = adminNotification.NotifyCompanyExpiredAsync(
|
||||
company.Id, company.CompanyName,
|
||||
company.PrimaryContactEmail ?? string.Empty, expiredDate);
|
||||
}
|
||||
else if (company.SubscriptionStatus == SubscriptionStatus.Active && today > endDate)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Company {Id} ({Name}) subscription ended. Entering grace period.",
|
||||
company.Id, company.CompanyName);
|
||||
if (gracePeriodDays == 0)
|
||||
{
|
||||
// No grace period configured — expire immediately without going through GracePeriod.
|
||||
// Trials always land here since gracePeriodAppliesToTrials defaults to false.
|
||||
_logger.LogInformation(
|
||||
"Company {Id} ({Name}) subscription ended with no grace period. Marking Expired and deactivating.",
|
||||
company.Id, company.CompanyName);
|
||||
|
||||
company.SubscriptionStatus = SubscriptionStatus.GracePeriod;
|
||||
company.UpdatedAt = DateTime.UtcNow;
|
||||
company.UpdatedBy = "System";
|
||||
company.SubscriptionStatus = SubscriptionStatus.Expired;
|
||||
company.IsActive = false;
|
||||
company.UpdatedAt = DateTime.UtcNow;
|
||||
company.UpdatedBy = "System";
|
||||
|
||||
await WriteAuditLogAsync(db, company,
|
||||
$"Auto-moved to GracePeriod: subscription ended {endDate:d}. Grace period expires {expiredDate:d}.");
|
||||
await WriteAuditLogAsync(db, company,
|
||||
$"Auto-expired: subscription ended {endDate:d} with no grace period.");
|
||||
|
||||
// Send "grace period started" email to company immediately
|
||||
await SendEmailIfNotSentAsync(db, emailService, company, today,
|
||||
NotificationType.SubscriptionExpiryReminder,
|
||||
daysBeforeExpiry: 0,
|
||||
gracePeriodDays,
|
||||
ct);
|
||||
_ = adminNotification.NotifyCompanyExpiredAsync(
|
||||
company.Id, company.CompanyName,
|
||||
company.PrimaryContactEmail ?? string.Empty, endDate);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Company {Id} ({Name}) subscription ended. Entering {Days}-day grace period.",
|
||||
company.Id, company.CompanyName, gracePeriodDays);
|
||||
|
||||
// Notify platform admin
|
||||
_ = adminNotification.NotifyCompanyGracePeriodAsync(
|
||||
company.Id, company.CompanyName,
|
||||
company.PrimaryContactEmail ?? string.Empty, expiredDate);
|
||||
company.SubscriptionStatus = SubscriptionStatus.GracePeriod;
|
||||
company.UpdatedAt = DateTime.UtcNow;
|
||||
company.UpdatedBy = "System";
|
||||
|
||||
await WriteAuditLogAsync(db, company,
|
||||
$"Auto-moved to GracePeriod: subscription ended {endDate:d}. Grace period expires {expiredDate:d}.");
|
||||
|
||||
await SendEmailIfNotSentAsync(db, emailService, company, today,
|
||||
NotificationType.SubscriptionExpiryReminder,
|
||||
daysBeforeExpiry: 0,
|
||||
gracePeriodDays,
|
||||
ct);
|
||||
|
||||
_ = adminNotification.NotifyCompanyGracePeriodAsync(
|
||||
company.Id, company.CompanyName,
|
||||
company.PrimaryContactEmail ?? string.Empty, expiredDate);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reminder emails (only while still Active) ────────────────────
|
||||
|
||||
Reference in New Issue
Block a user