Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -0,0 +1,121 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Web.BackgroundServices;
/// <summary>
/// Runs once daily and purges AuditLog rows older than the AuditLogRetentionDays platform setting (default 365).
/// Deletes in batches to avoid large single-transaction locks.
/// </summary>
public class AuditLogRetentionBackgroundService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<AuditLogRetentionBackgroundService> _logger;
private static readonly TimeOnly RunTime = new(2, 0); // 2:00 AM server local time
private const int BatchSize = 500;
/// <summary>
/// Initializes the service with the required scope factory and logger.
/// <see cref="IServiceScopeFactory"/> is used because <see cref="BackgroundService"/> is a singleton
/// and scoped services (DbContext, IPlatformSettingsService) cannot be injected directly.
/// </summary>
public AuditLogRetentionBackgroundService(
IServiceScopeFactory scopeFactory,
ILogger<AuditLogRetentionBackgroundService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
/// <summary>
/// Long-running loop that wakes once per day at <see cref="RunTime"/> (2:00 AM local) to run
/// the purge job. Scheduled at 2 AM to run during off-hours when transaction volume is lowest,
/// minimizing the risk of lock contention with active queries.
/// </summary>
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("AuditLogRetentionBackgroundService started.");
while (!stoppingToken.IsCancellationRequested)
{
var delay = GetDelayUntilNextRun();
_logger.LogDebug("Audit log retention sleeping {Delay} until next run.", delay);
await Task.Delay(delay, stoppingToken);
if (stoppingToken.IsCancellationRequested) break;
await RunRetentionAsync(stoppingToken);
}
}
/// <summary>
/// Reads the <c>AuditLogRetentionDays</c> platform setting (default 365) and deletes all
/// <c>AuditLog</c> rows whose <c>Timestamp</c> is older than the computed cutoff date.
/// Deletions are performed in batches of <see cref="BatchSize"/> rows with a 200 ms pause
/// between batches to avoid holding a large exclusive lock that would block audit writes
/// from concurrent requests.
/// </summary>
private async Task RunRetentionAsync(CancellationToken ct)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var platformSettings = scope.ServiceProvider.GetRequiredService<IPlatformSettingsService>();
var raw = await platformSettings.GetAsync(PlatformSettingKeys.AuditLogRetentionDays);
var retentionDays = int.TryParse(raw, out var d) ? d : 365;
var cutoff = DateTime.UtcNow.AddDays(-retentionDays);
_logger.LogInformation("Audit log retention: purging entries older than {Cutoff:yyyy-MM-dd} (retention={Days}d)",
cutoff, retentionDays);
try
{
var totalDeleted = 0;
int deleted;
do
{
// Batch deletes to avoid long-running transactions
deleted = await db.AuditLogs
.Where(a => a.Timestamp < cutoff)
.OrderBy(a => a.Timestamp)
.Take(BatchSize)
.ExecuteDeleteAsync(ct);
totalDeleted += deleted;
if (deleted > 0)
await Task.Delay(200, ct); // brief pause between batches
} while (deleted == BatchSize && !ct.IsCancellationRequested);
if (totalDeleted > 0)
_logger.LogInformation("Audit log retention: deleted {Count} old entries.", totalDeleted);
else
_logger.LogDebug("Audit log retention: nothing to purge.");
}
catch (OperationCanceledException) { /* shutting down — fine */ }
catch (Exception ex)
{
_logger.LogError(ex, "Audit log retention job failed.");
}
}
/// <summary>
/// Calculates how long to sleep before the next scheduled run at <see cref="RunTime"/> (2:00 AM local).
/// Uses local server time so the maintenance window aligns with the server's overnight period
/// regardless of the UTC offset. Returns the delay to tomorrow's 2 AM if that time has already
/// passed for today.
/// </summary>
private static TimeSpan GetDelayUntilNextRun()
{
var now = DateTime.Now;
var next = DateTime.Today.Add(RunTime.ToTimeSpan());
if (next <= now)
next = next.AddDays(1);
return next - now;
}
}
@@ -0,0 +1,223 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Entities;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Web.BackgroundServices;
/// <summary>
/// Runs once per day and sends overdue-invoice payment reminders to customers
/// for companies that have PaymentRemindersEnabled = true.
/// </summary>
public class PaymentReminderBackgroundService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<PaymentReminderBackgroundService> _logger;
// Run at 9:00 AM server local time every day.
private static readonly TimeOnly RunTime = new(9, 0);
/// <summary>
/// Initializes the service with a scope factory and logger.
/// <see cref="IServiceScopeFactory"/> is required because <see cref="BackgroundService"/> is a singleton
/// and scoped services (DbContext, INotificationService) cannot be injected directly.
/// </summary>
public PaymentReminderBackgroundService(
IServiceScopeFactory scopeFactory,
ILogger<PaymentReminderBackgroundService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
/// <summary>
/// Long-running loop that wakes once per day at <see cref="RunTime"/> (9:00 AM local) and
/// triggers the reminder check. Uses <see cref="Task.Delay"/> with the cancellation token so
/// that the service shuts down promptly when the application stops rather than waiting for the
/// next scheduled window.
/// </summary>
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("PaymentReminderBackgroundService started.");
while (!stoppingToken.IsCancellationRequested)
{
var delay = GetDelayUntilNextRun();
_logger.LogDebug("Payment reminder service sleeping for {Delay} until next run.", delay);
try
{
await Task.Delay(delay, stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
if (stoppingToken.IsCancellationRequested) break;
await RunAsync(stoppingToken);
}
_logger.LogInformation("PaymentReminderBackgroundService stopped.");
}
/// <summary>
/// Executes the daily reminder pass: opens a DI scope, loads companies with reminders enabled,
/// and delegates per-company processing to <see cref="ProcessCompanyAsync"/>.
/// A fresh scope is used per run so that the DbContext change tracker is clean each day.
/// Top-level exceptions are caught here so that a single bad run does not kill the background
/// service loop.
/// </summary>
private async Task RunAsync(CancellationToken ct)
{
_logger.LogInformation("Running daily payment reminder check.");
try
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var notificationService = scope.ServiceProvider.GetRequiredService<INotificationService>();
var today = DateTime.UtcNow.Date;
// Load all companies that have reminders enabled.
// IgnoreQueryFilters is required because there is no HTTP context (tenant filter
// would otherwise resolve to CompanyId == null and return no rows).
var enabledPrefs = await db.CompanyPreferences
.IgnoreQueryFilters()
.AsNoTracking()
.Where(p => p.PaymentRemindersEnabled && !p.IsDeleted)
.ToListAsync(ct);
if (enabledPrefs.Count == 0)
{
_logger.LogDebug("No companies have payment reminders enabled.");
return;
}
foreach (var prefs in enabledPrefs)
{
if (ct.IsCancellationRequested) break;
var thresholds = ParseThresholds(prefs.PaymentReminderDays);
if (thresholds.Length == 0) continue;
await ProcessCompanyAsync(db, notificationService, prefs.CompanyId, thresholds, today, ct);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Payment reminder run failed.");
}
}
/// <summary>
/// Sends payment reminder notifications for a single company's overdue invoices.
/// Only fires when the number of days overdue exactly matches one of the configured
/// <paramref name="thresholds"/>; this prevents re-sending on every subsequent day.
/// Deduplication via the <c>NotificationLogs</c> table ensures a reminder is not sent twice
/// in the same calendar day if the service is restarted or run manually.
/// </summary>
private async Task ProcessCompanyAsync(
ApplicationDbContext db,
INotificationService notificationService,
int companyId,
int[] thresholds,
DateTime today,
CancellationToken ct)
{
// Load overdue invoices (Sent, PartiallyPaid, or Overdue) with a past due date.
// IgnoreQueryFilters to bypass tenant filter (no HTTP context in background service).
var overdueInvoices = await db.Invoices
.IgnoreQueryFilters()
.Include(i => i.Customer)
.Where(i =>
i.CompanyId == companyId &&
!i.IsDeleted &&
i.DueDate.HasValue &&
i.DueDate.Value.Date < today &&
(i.Status == InvoiceStatus.Sent ||
i.Status == InvoiceStatus.PartiallyPaid ||
i.Status == InvoiceStatus.Overdue))
.ToListAsync(ct);
if (overdueInvoices.Count == 0) return;
// Load today's already-sent reminder logs for this company to avoid duplicates
var todayStart = today;
var todayEnd = today.AddDays(1);
var sentTodayInvoiceIds = await db.NotificationLogs
.IgnoreQueryFilters()
.AsNoTracking()
.Where(l =>
l.CompanyId == companyId &&
!l.IsDeleted &&
l.NotificationType == NotificationType.PaymentReminder &&
l.InvoiceId.HasValue &&
l.SentAt >= todayStart &&
l.SentAt < todayEnd &&
l.Status == NotificationStatus.Sent)
.Select(l => l.InvoiceId!.Value)
.Distinct()
.ToListAsync(ct);
var sentTodaySet = new HashSet<int>(sentTodayInvoiceIds);
foreach (var invoice in overdueInvoices)
{
if (ct.IsCancellationRequested) break;
if (sentTodaySet.Contains(invoice.Id)) continue;
var daysOverdue = (int)(today - invoice.DueDate!.Value.Date).TotalDays;
// Only send if daysOverdue matches one of the configured thresholds
if (!thresholds.Contains(daysOverdue)) continue;
_logger.LogInformation(
"Sending payment reminder for invoice {InvoiceNumber} (company {CompanyId}, {DaysOverdue} days overdue).",
invoice.InvoiceNumber, companyId, daysOverdue);
await notificationService.NotifyPaymentReminderAsync(invoice, daysOverdue);
}
}
/// <summary>
/// Parses the comma-separated reminder threshold string stored in <c>CompanyPreferences.PaymentReminderDays</c>
/// (e.g., "1,7,14,30") into a sorted, deduplicated array of positive integers.
/// Invalid or zero values are silently skipped so that poorly entered config data does not
/// prevent valid thresholds from working.
/// </summary>
private static int[] ParseThresholds(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return [];
return raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => int.TryParse(s, out var n) && n > 0 ? n : (int?)null)
.Where(n => n.HasValue)
.Select(n => n!.Value)
.Distinct()
.OrderBy(n => n)
.ToArray();
}
/// <summary>
/// Calculates how long to sleep before the next scheduled run at <see cref="RunTime"/>.
/// Uses local server time rather than UTC because <see cref="RunTime"/> is expressed in local
/// time (9:00 AM) so the reminder arrives during business hours regardless of the server's
/// UTC offset. If the scheduled time has already passed today, the next run is tomorrow.
/// </summary>
private static TimeSpan GetDelayUntilNextRun()
{
var now = DateTime.Now;
var nextRun = now.Date.Add(RunTime.ToTimeSpan());
if (nextRun <= now)
nextRun = nextRun.AddDays(1);
return nextRun - now;
}
}
@@ -0,0 +1,202 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Web.BackgroundServices;
/// <summary>
/// Runs once per day and sends an in-app notification to any company that has not yet completed
/// the setup wizard. The reminder schedule is: first nudge on day 3, then every 7 days after that
/// (days 3, 10, 17, 24, …). Deduplication is done by querying the most recent
/// <c>SetupWizardReminder</c> notification in <c>InAppNotifications</c> — no extra DB columns
/// are needed.
/// </summary>
public class SetupWizardReminderBackgroundService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<SetupWizardReminderBackgroundService> _logger;
// First reminder fires when the company is at least this many days old.
private const int FirstReminderAfterDays = 3;
// Subsequent reminders fire this many days after the previous one.
private const int RepeatIntervalDays = 7;
// Run at 9:00 AM server local time each day — same cadence as other daily services.
private static readonly TimeOnly RunTime = new(9, 0);
// Matches the string stored in InAppNotification.NotificationType for wizard reminders.
private const string ReminderNotificationType = "SetupWizardReminder";
/// <summary>
/// Initializes the service with a scope factory (required because BackgroundService is a
/// singleton and both DbContext and IInAppNotificationService are scoped) and a logger.
/// </summary>
public SetupWizardReminderBackgroundService(
IServiceScopeFactory scopeFactory,
ILogger<SetupWizardReminderBackgroundService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
/// <summary>
/// Long-running loop that sleeps until 9:00 AM each day then delegates to
/// <see cref="RunAsync"/>. Uses the cancellation token so the service stops promptly on
/// application shutdown rather than waiting for the next wake window.
/// </summary>
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("SetupWizardReminderBackgroundService started.");
while (!stoppingToken.IsCancellationRequested)
{
var delay = GetDelayUntilNextRun();
_logger.LogDebug("Setup wizard reminder service sleeping for {Delay} until next run.", delay);
try
{
await Task.Delay(delay, stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
if (stoppingToken.IsCancellationRequested) break;
await RunAsync(stoppingToken);
}
_logger.LogInformation("SetupWizardReminderBackgroundService stopped.");
}
/// <summary>
/// Opens a fresh DI scope, loads all companies with an incomplete setup wizard, and sends
/// a reminder to each one that is due for a nudge today. A new scope per run keeps the
/// DbContext change tracker clean across daily executions. Top-level exceptions are caught
/// here so that a single bad run does not kill the service loop.
/// </summary>
private async Task RunAsync(CancellationToken ct)
{
_logger.LogInformation("Running daily setup-wizard reminder check.");
try
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var notificationService = scope.ServiceProvider.GetRequiredService<IInAppNotificationService>();
var today = DateTime.UtcNow.Date;
// Load all incomplete companies. IgnoreQueryFilters is required — there is no HTTP
// context in a background service so the tenant filter would resolve to null and
// return zero rows.
var incompletePrefs = await db.CompanyPreferences
.IgnoreQueryFilters()
.AsNoTracking()
.Where(p => !p.IsDeleted && !p.SetupWizardCompleted)
.Select(p => new { p.CompanyId, p.CreatedAt })
.ToListAsync(ct);
if (incompletePrefs.Count == 0)
{
_logger.LogDebug("All companies have completed the setup wizard — nothing to do.");
return;
}
// Pull the most recent SetupWizardReminder notification per company in one query so
// we don't do N round-trips.
var companyIds = incompletePrefs.Select(p => p.CompanyId).ToList();
var lastReminderByCompany = await db.InAppNotifications
.IgnoreQueryFilters()
.AsNoTracking()
.Where(n =>
!n.IsDeleted &&
n.NotificationType == ReminderNotificationType &&
companyIds.Contains(n.CompanyId))
.GroupBy(n => n.CompanyId)
.Select(g => new
{
CompanyId = g.Key,
LastSentAt = g.Max(n => n.CreatedAt),
Count = g.Count()
})
.ToDictionaryAsync(x => x.CompanyId, ct);
foreach (var prefs in incompletePrefs)
{
if (ct.IsCancellationRequested) break;
var companyAgedays = (today - prefs.CreatedAt.Date).Days;
if (!lastReminderByCompany.TryGetValue(prefs.CompanyId, out var lastReminder))
{
// No reminder sent yet — fire the first one once the company is old enough.
if (companyAgedays < FirstReminderAfterDays) continue;
await SendReminderAsync(notificationService, prefs.CompanyId, reminderNumber: 1, ct);
}
else
{
// A reminder was sent before — repeat if the interval has elapsed.
var daysSinceLast = (today - lastReminder.LastSentAt.Date).Days;
if (daysSinceLast < RepeatIntervalDays) continue;
await SendReminderAsync(notificationService, prefs.CompanyId,
reminderNumber: lastReminder.Count + 1, ct);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Setup wizard reminder run failed.");
}
}
/// <summary>
/// Sends a single setup-wizard reminder notification for the given company. The title and
/// message escalate slightly across reminder numbers: the first nudge is friendly; later ones
/// are more direct. The link takes the user straight to Step 1 of the wizard.
/// </summary>
private static async Task SendReminderAsync(
IInAppNotificationService notificationService,
int companyId,
int reminderNumber,
CancellationToken ct)
{
var (title, message) = reminderNumber switch
{
1 => (
"Complete your setup wizard",
"Welcome! Your setup wizard is still in progress. Taking a few minutes to finish it will unlock accurate pricing, oven scheduling, and more."),
2 => (
"Your setup isn't finished yet",
"You're leaving features on the table. Complete the setup wizard to configure your operating costs, equipment, and pricing tiers."),
_ => (
"Reminder: setup wizard incomplete",
"Your account setup is still incomplete. Finish the setup wizard so your team can get the most out of Powder Coating Logix.")
};
await notificationService.CreateAsync(
companyId,
title,
message,
ReminderNotificationType,
link: "/SetupWizard");
}
/// <summary>
/// Calculates the delay until the next 9:00 AM local run. If that time has already passed
/// today the next run is scheduled for tomorrow — same logic used by other daily services.
/// </summary>
private static TimeSpan GetDelayUntilNextRun()
{
var now = DateTime.Now;
var nextRun = now.Date.Add(RunTime.ToTimeSpan());
if (nextRun <= now)
nextRun = nextRun.AddDays(1);
return nextRun - now;
}
}
@@ -0,0 +1,123 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Web.BackgroundServices;
/// <summary>
/// Runs once daily and purges StripeWebhookEvents older than the StripeWebhookRetentionDays platform setting (default 90).
/// Processed events are safe to purge; unprocessed ones are kept regardless of age
/// so nothing is silently dropped.
/// </summary>
public class StripeWebhookRetentionBackgroundService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<StripeWebhookRetentionBackgroundService> _logger;
private static readonly TimeOnly RunTime = new(3, 0); // 3:00 AM — stagger after AuditLog (2 AM)
private const int BatchSize = 500;
/// <summary>
/// Initializes the service with the required scope factory and logger.
/// <see cref="IServiceScopeFactory"/> is required because <see cref="BackgroundService"/> is a singleton
/// and scoped EF DbContext instances cannot be injected directly into it.
/// </summary>
public StripeWebhookRetentionBackgroundService(
IServiceScopeFactory scopeFactory,
ILogger<StripeWebhookRetentionBackgroundService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
/// <summary>
/// Long-running loop that wakes once per day at <see cref="RunTime"/> (3:00 AM local) to purge
/// old webhook event records. Scheduled at 3 AM — one hour after the audit log retention job —
/// to stagger database-intensive maintenance tasks and reduce peak disk I/O.
/// </summary>
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("StripeWebhookRetentionBackgroundService started.");
while (!stoppingToken.IsCancellationRequested)
{
var delay = GetDelayUntilNextRun();
_logger.LogDebug("Stripe webhook retention sleeping {Delay} until next run.", delay);
await Task.Delay(delay, stoppingToken);
if (stoppingToken.IsCancellationRequested) break;
await RunRetentionAsync(stoppingToken);
}
}
/// <summary>
/// Reads the <c>StripeWebhookRetentionDays</c> platform setting (default 90) and hard-deletes
/// processed <c>StripeWebhookEvent</c> rows older than the cutoff date.
/// Unprocessed events (where <c>ProcessedAt</c> is null) are always preserved regardless of age
/// so that no webhook is silently dropped — a stuck event will surface in monitoring rather than
/// disappear. Deletions are batched with a 200 ms pause between iterations to minimize lock pressure.
/// </summary>
private async Task RunRetentionAsync(CancellationToken ct)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var platformSettings = scope.ServiceProvider.GetRequiredService<IPlatformSettingsService>();
var raw = await platformSettings.GetAsync(PlatformSettingKeys.StripeWebhookRetentionDays);
var retentionDays = int.TryParse(raw, out var d) ? d : 90;
var cutoff = DateTime.UtcNow.AddDays(-retentionDays);
_logger.LogInformation(
"Stripe webhook retention: purging processed events older than {Cutoff:yyyy-MM-dd} (retention={Days}d)",
cutoff, retentionDays);
try
{
var totalDeleted = 0;
int deleted;
do
{
// Only purge events that have been processed — never drop unprocessed ones
deleted = await db.StripeWebhookEvents
.Where(e => e.ReceivedAt < cutoff && e.ProcessedAt != null)
.OrderBy(e => e.ReceivedAt)
.Take(BatchSize)
.ExecuteDeleteAsync(ct);
totalDeleted += deleted;
if (deleted > 0)
await Task.Delay(200, ct);
} while (deleted == BatchSize && !ct.IsCancellationRequested);
if (totalDeleted > 0)
_logger.LogInformation("Stripe webhook retention: deleted {Count} old events.", totalDeleted);
else
_logger.LogDebug("Stripe webhook retention: nothing to purge.");
}
catch (OperationCanceledException) { /* shutting down */ }
catch (Exception ex)
{
_logger.LogError(ex, "Stripe webhook retention job failed.");
}
}
/// <summary>
/// Calculates how long to sleep before the next scheduled run at <see cref="RunTime"/> (3:00 AM local).
/// Uses local server time so the window aligns with the server's overnight low-traffic period.
/// Returns the delay to tomorrow's 3 AM if that time has already passed today.
/// </summary>
private static TimeSpan GetDelayUntilNextRun()
{
var now = DateTime.Now;
var next = DateTime.Today.Add(RunTime.ToTimeSpan());
if (next <= now)
next = next.AddDays(1);
return next - now;
}
}
@@ -0,0 +1,339 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.BackgroundServices;
/// <summary>
/// Runs once per day and:
/// 1. Advances Active → GracePeriod and GracePeriod → Expired based on SubscriptionEndDate.
/// 2. Sends reminder emails at 14, 7, 3, and 1 day(s) before expiry.
///
/// Skips companies that are IsComped or managed by Stripe (StripeSubscriptionId set).
/// </summary>
public class SubscriptionExpiryBackgroundService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<SubscriptionExpiryBackgroundService> _logger;
// Run at 8:00 AM server local time every day.
private static readonly TimeOnly RunTime = new(8, 0);
// Days-before-expiry at which reminder emails are sent.
private static readonly int[] ReminderDays = [14, 7, 3, 1];
/// <summary>
/// Initializes the service with a scope factory and logger.
/// <see cref="IServiceScopeFactory"/> is required because <see cref="BackgroundService"/> is a singleton
/// while the services it consumes (DbContext, IEmailService) are scoped.
/// </summary>
public SubscriptionExpiryBackgroundService(
IServiceScopeFactory scopeFactory,
ILogger<SubscriptionExpiryBackgroundService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
/// <summary>
/// Long-running loop that wakes once per day at <see cref="RunTime"/> (8:00 AM local) and runs
/// the expiry check. Scheduled 1 hour before the payment reminder service (9 AM) so that
/// subscription state transitions are committed before reminder emails reference subscription status.
/// </summary>
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("SubscriptionExpiryBackgroundService started.");
while (!stoppingToken.IsCancellationRequested)
{
var delay = GetDelayUntilNextRun();
_logger.LogDebug("Subscription expiry service sleeping {Delay} until next run.", delay);
try { await Task.Delay(delay, stoppingToken); }
catch (OperationCanceledException) { break; }
if (stoppingToken.IsCancellationRequested) break;
await RunAsync(stoppingToken);
}
_logger.LogInformation("SubscriptionExpiryBackgroundService stopped.");
}
/// <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.
/// </summary>
private async Task RunAsync(CancellationToken ct)
{
_logger.LogInformation("Running daily subscription expiry check.");
try
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var emailService = scope.ServiceProvider.GetRequiredService<IEmailService>();
var adminNotification = scope.ServiceProvider.GetRequiredService<IAdminNotificationService>();
var today = DateTime.UtcNow.Date;
// Load all non-comped, non-Stripe-managed companies that might need attention.
var companies = await db.Companies
.IgnoreQueryFilters()
.Where(c =>
!c.IsDeleted &&
!c.IsComped &&
string.IsNullOrEmpty(c.StripeSubscriptionId) &&
c.SubscriptionEndDate.HasValue &&
(c.SubscriptionStatus == SubscriptionStatus.Active ||
c.SubscriptionStatus == SubscriptionStatus.GracePeriod))
.ToListAsync(ct);
_logger.LogDebug("Found {Count} companies to evaluate.", companies.Count);
foreach (var company in companies)
{
if (ct.IsCancellationRequested) break;
await ProcessCompanyAsync(db, emailService, adminNotification, company, today, ct);
}
await db.SaveChangesAsync(ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Subscription expiry run failed.");
}
}
/// <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.
/// 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.
/// </summary>
private async Task ProcessCompanyAsync(
ApplicationDbContext db,
IEmailService emailService,
IAdminNotificationService adminNotification,
PowderCoating.Core.Entities.Company company,
DateTime today,
CancellationToken ct)
{
var endDate = company.SubscriptionEndDate!.Value.Date;
var gracePeriodDays = AppConstants.SubscriptionConstants.GracePeriodDays;
var expiredDate = endDate.AddDays(gracePeriodDays);
// ── Status transitions ────────────────────────────────────────────
if (company.SubscriptionStatus == SubscriptionStatus.GracePeriod && today >= expiredDate)
{
_logger.LogInformation(
"Company {Id} ({Name}) grace period ended. Marking Expired and deactivating.",
company.Id, company.CompanyName);
company.SubscriptionStatus = SubscriptionStatus.Expired;
company.IsActive = false;
company.UpdatedAt = DateTime.UtcNow;
company.UpdatedBy = "System";
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);
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}.");
// Send "grace period started" email to company immediately
await SendEmailIfNotSentAsync(db, emailService, company, today,
NotificationType.SubscriptionExpiryReminder,
daysBeforeExpiry: 0,
ct);
// Notify platform admin
_ = adminNotification.NotifyCompanyGracePeriodAsync(
company.Id, company.CompanyName,
company.PrimaryContactEmail ?? string.Empty, expiredDate);
}
// ── Reminder emails (only while still Active) ────────────────────
if (company.SubscriptionStatus == SubscriptionStatus.Active)
{
var daysUntilExpiry = (endDate - today).Days;
if (ReminderDays.Contains(daysUntilExpiry))
{
await SendEmailIfNotSentAsync(db, emailService, company, today,
NotificationType.SubscriptionExpiryReminder,
daysBeforeExpiry: daysUntilExpiry,
ct);
}
}
}
/// <summary>
/// Sends a subscription expiry reminder or grace-period email to the company's primary contact,
/// then records the send in <c>NotificationLogs</c> (both on success and failure).
/// Deduplication is achieved by checking the log for the same type, company, and day+subject-tag
/// before sending, which prevents duplicate emails if the service restarts mid-run.
/// <paramref name="daysBeforeExpiry"/> = 0 signals a grace-period notice rather than a pre-expiry reminder.
/// </summary>
private static async Task SendEmailIfNotSentAsync(
ApplicationDbContext db,
IEmailService emailService,
PowderCoating.Core.Entities.Company company,
DateTime today,
NotificationType notificationType,
int daysBeforeExpiry,
CancellationToken ct)
{
if (string.IsNullOrEmpty(company.PrimaryContactEmail)) return;
// Prevent duplicate sends — check if we already sent this type today for this company
var alreadySent = await db.NotificationLogs
.IgnoreQueryFilters()
.AsNoTracking()
.AnyAsync(l =>
l.CompanyId == company.Id &&
!l.IsDeleted &&
l.NotificationType == notificationType &&
l.SentAt >= today &&
l.SentAt < today.AddDays(1) &&
l.Status == NotificationStatus.Sent &&
l.Subject != null && l.Subject.Contains($"[{daysBeforeExpiry}d]"),
ct);
if (alreadySent) return;
var (subject, plain, html) = BuildEmail(company, daysBeforeExpiry);
var (success, error) = await emailService.SendEmailAsync(
company.PrimaryContactEmail,
company.CompanyName,
subject,
plain,
html);
db.NotificationLogs.Add(new NotificationLog
{
CompanyId = company.Id,
Channel = NotificationChannel.Email,
NotificationType = notificationType,
Status = success ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = company.CompanyName,
Recipient = company.PrimaryContactEmail,
Subject = subject,
Message = plain,
ErrorMessage = error,
SentAt = DateTime.UtcNow
});
}
/// <summary>
/// Constructs the subject line and both plain-text and HTML email bodies for the reminder or
/// grace-period notice. The subject embeds a "[Nd]" tag (e.g., "[7d]", "[0d]") which the
/// deduplication query in <see cref="SendEmailIfNotSentAsync"/> uses as a secondary key to
/// distinguish emails sent on different days within the same calendar day.
/// </summary>
private static (string subject, string plain, string html) BuildEmail(
PowderCoating.Core.Entities.Company company,
int daysBeforeExpiry)
{
var endDate = company.SubscriptionEndDate!.Value.Date;
if (daysBeforeExpiry == 0)
{
// Grace period notice
var graceDays = AppConstants.SubscriptionConstants.GracePeriodDays;
var expiredOn = endDate.AddDays(graceDays).ToString("MMMM d, yyyy");
var subject = $"[0d] Your Powder Coating Logix subscription has expired";
var plain = $"Hi {company.CompanyName},\n\n" +
$"Your Powder Coating Logix subscription expired on {endDate:MMMM d, yyyy}. " +
$"You have a {graceDays}-day grace period — your account will be fully suspended on {expiredOn} if not renewed.\n\n" +
$"Log in to renew your subscription and keep your shop running.\n\nThank you,\nThe Powder Coating Logix Team";
var html = $"<p>Hi <strong>{company.CompanyName}</strong>,</p>" +
$"<p>Your Powder Coating Logix subscription expired on <strong>{endDate:MMMM d, yyyy}</strong>.</p>" +
$"<p>You have a <strong>{graceDays}-day grace period</strong>. Your account will be fully suspended on <strong>{expiredOn}</strong> if not renewed.</p>" +
$"<p>Log in to renew your subscription and keep your shop running.</p>" +
$"<p>Thank you,<br>The Powder Coating Logix Team</p>";
return (subject, plain, html);
}
else
{
var expiresOn = endDate.ToString("MMMM d, yyyy");
var subject = $"[{daysBeforeExpiry}d] Your Powder Coating Logix subscription expires in {daysBeforeExpiry} day{(daysBeforeExpiry == 1 ? "" : "s")}";
var plain = $"Hi {company.CompanyName},\n\n" +
$"This is a reminder that your Powder Coating Logix subscription expires on {expiresOn} " +
$"({daysBeforeExpiry} day{(daysBeforeExpiry == 1 ? "" : "s")} from today).\n\n" +
$"Log in to renew your subscription before it expires.\n\nThank you,\nThe Powder Coating Logix Team";
var html = $"<p>Hi <strong>{company.CompanyName}</strong>,</p>" +
$"<p>Your Powder Coating Logix subscription expires on <strong>{expiresOn}</strong> " +
$"({daysBeforeExpiry} day{(daysBeforeExpiry == 1 ? "" : "s")} from today).</p>" +
$"<p>Log in to renew your subscription before it expires.</p>" +
$"<p>Thank you,<br>The Powder Coating Logix Team</p>";
return (subject, plain, html);
}
}
/// <summary>
/// Writes a system-generated audit log entry recording the subscription status transition.
/// Uses raw SQL rather than the EF change tracker because <c>AuditLog</c> does not inherit
/// <c>BaseEntity</c> and its <c>Timestamp</c> column is non-nullable without a default;
/// inserting via raw SQL also avoids accidentally triggering any EF interceptors that assume
/// a user context is present.
/// </summary>
private static async Task WriteAuditLogAsync(
ApplicationDbContext db,
PowderCoating.Core.Entities.Company company,
string message)
{
await db.Database.ExecuteSqlRawAsync("""
INSERT INTO AuditLogs (UserId, UserName, CompanyId, CompanyName, Action, EntityType, EntityId,
EntityDescription, OldValues, NewValues, IpAddress, Timestamp)
VALUES ({0},{1},{2},{3},{4},{5},{6},{7},{8},{9},{10},{11})
""",
(object?)null,
"System",
(object?)null,
(object?)null,
"Updated",
"Company",
company.Id.ToString(),
company.CompanyName,
(object?)null,
message,
(object?)null,
DateTime.UtcNow);
}
/// <summary>
/// Calculates the delay until the next scheduled run at <see cref="RunTime"/> (8:00 AM local).
/// Uses local time so the check fires during business hours on the server's clock.
/// If 8 AM has already passed today, returns the delay to 8 AM tomorrow.
/// </summary>
private static TimeSpan GetDelayUntilNextRun()
{
var now = DateTime.Now;
var nextRun = now.Date.Add(RunTime.ToTimeSpan());
if (nextRun <= now) nextRun = nextRun.AddDays(1);
return nextRun - now;
}
}