Appearance
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:
identityschema,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 sent2. 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 renewed3. 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 suspended4. 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 deactivatedJob 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 TestcontainersFakeClock— deterministic time controlNullLogger<T>.Instance— suppress log output unless verifying logsNSubstitutemocks forINotificationService
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.