Skip to content

Background Jobs

Phase 2 introduces four scheduled background jobs managed by Hangfire with PostgreSQL storage. All jobs follow the same pattern: they run on a schedule, operate against the MembersDbContext, and produce notifications or state changes.

Technology

  • Scheduler: Hangfire + Hangfire.PostgreSql
  • Dashboard: /hangfire (requires admin authorization)
  • Storage: identity schema, hangfire.* tables
  • Job discovery: Registered explicitly in MembersModuleRegistration

Job Catalog

1. CheckExpiringSubscriptionsJob

ID: members:check-expiring-subscriptionsSchedule: Daily (configurable via HangfireOptions) Purpose: Sends renewal reminder emails to members with active subscriptions expiring within the next 7 days.

Run → Find subscriptions where:
        status = active
        AND endsAt > now
        AND endsAt <= now + 7 days
    → For each: SendAsync("renewal-reminder" email)
    → Log count sent

2. ProcessAutoRenewalsJob

ID: members:process-auto-renewalsSchedule: Daily Purpose: Automatically renews subscriptions with AutoRenew = true that expire within 24 hours.

Run → Find subscriptions where:
        status = active
        AND autoRenew = true
        AND endsAt <= now + 24h
    → For each:
        1. Create new Subscription (startedAt = old endsAt)
        2. Update member status to active
        3. Send "auto-renewal-confirmed" notification
    → Log count renewed

3. SuspendUnpaidMembersJob

ID: members:suspend-unpaid-membersSchedule: Daily Purpose: Suspends members whose subscription expired without renewal, moving them from payment_pending to suspended.

Run → Find members where:
        status = payment_pending
        AND subscription.endsAt < now - grace period
    → For each: UpdateStatus(suspended, reason: "unpaid")
    → Log count suspended

4. CleanupExpiredPromoCodesJob

ID: members:cleanup-expired-promo-codesSchedule: Nightly Purpose: Deactivates promotional codes whose ValidUntil has passed. Prevents expired codes from appearing as valid.

Run → Find promo codes where:
        isActive = true
        AND validUntil < now
    → Batch update: isActive = false
    → Log count deactivated

Job Pattern

All jobs follow the same structure:

csharp
// Job class — plain class with RunAsync method
internal sealed class ExampleJob(MembersDbContext db, INotificationService notifications, IClock clock, ILogger<ExampleJob> logger)
{
    public async Task RunAsync(CancellationToken ct)
    {
        // 1. Query relevant data
        var items = await db.SomeEntities.Where(...).ToListAsync(ct);

        // 2. Process each item
        foreach (var item in items)
        {
            await notifications.SendAsync(new NotificationRequest(...), ct);
        }

        // 3. Log result
        logger.LogInformation("Job processed {Count} item(s)", items.Count);
    }
}

Registration

Jobs are registered in MembersModuleRegistration.AddMembersModule():

csharp
builder.Services.AddHangfireServer();

// Register recurring jobs after app build
app.Services.GetRequiredService<IRecurringJobManager>()
    .AddOrUpdate<CheckExpiringSubscriptionsJob>(
        BackgroundJobNames.CheckExpiringSubscriptions,
        job => job.RunAsync(CancellationToken.None),
        Cron.Daily);

Testing

Background jobs are tested at Level 4 (Background Job Tests) in tests/Wilo.Modules.Members.Tests/BackgroundJobs/. Tests use:

  • TestDbContextFactory — real PostgreSQL via Testcontainers
  • FakeClock — deterministic time control
  • NullLogger<T>.Instance — suppress log output unless verifying logs
  • NSubstitute mocks for INotificationService

See .claude/rules/api-testing.md §Level 4 for the full pattern.

Dashboard Access

The Hangfire dashboard is available at http://localhost:5000/hangfire in development. In production, it requires the SuperAdmin role. The authorization policy is configured in DependencyInjection/HangfireExtensions.cs.