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:
2026-05-25 09:43:41 -04:00
parent 04d16109ae
commit e4a256a6c4
33 changed files with 129 additions and 92 deletions
@@ -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 &gt; 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) ────────────────────