refinement phase 7

This commit is contained in:
hamid
2026-07-13 17:48:50 +03:30
parent 70268ecc06
commit 7edadadea1
30 changed files with 6971 additions and 154 deletions
@@ -42,7 +42,9 @@ public sealed class AdminPayoutsController(ISender sender) : BaseController
[HttpPost("batches")]
[ProducesOkApiResponseType<GeneratePayoutBatchResult>]
public async Task<IActionResult> Generate(GeneratePayoutBatchCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
// SystemInitiated is scheduler-only — neutralize any request-supplied value so an API caller can never
// record a batch without an authenticated admin initiator (refinement-phase-7).
=> OperationResult(await sender.Send(command with { SystemInitiated = false }, cancellationToken));
[HttpPost("batches/{id}/process")]
[ProducesOkApiResponseType<ExecutePayoutBatchResult>]
+29 -8
View File
@@ -110,22 +110,43 @@ builder.Services.ConfigureGrpcPluginServices();
var app = builder.Build();
// Integration tests (WebApplicationFactory, env "Testing") run on in-memory SQLite — the SQL Server
// migrations can't apply there; the test factory does EnsureCreated + seeding itself.
if (!app.Environment.IsEnvironment("Testing"))
// Deploy-time migration one-shot (refinement-phase-7): `dotnet run -- migrate` (or `<binary> migrate`) applies
// EF migrations + the idempotent seeders, then exits. Running DDL as a separate deploy step means normal boots —
// especially concurrent multi-instance start-ups — never race on schema, and the runtime login needs no
// permanent DDL rights.
if (args.Any(a => string.Equals(a, "migrate", StringComparison.OrdinalIgnoreCase)))
{
await app.ApplyMigrationsAsync();
await app.SeedDefaultUsersAsync();
// Development-only: a sandbox payment gateway (all-zeros merchant id) and a demo marketplace
// (nurses/variants/search rows, customers/patients) so the real-path screens aren't empty. Neither
// belongs in a deployed DB — a production gateway is an admin action, so this never runs in
// Production/Staging. Both are idempotent.
if (app.Environment.IsDevelopment())
{
await app.SeedPaymentGatewaysAsync();
await app.SeedDemoWorldAsync();
}
return;
}
// Integration tests (WebApplicationFactory, env "Testing") run on in-memory SQLite — the SQL Server
// migrations can't apply there; the test factory does EnsureCreated + seeding itself.
if (!app.Environment.IsEnvironment("Testing"))
{
if (app.Environment.IsDevelopment())
{
// Local convenience: apply migrations + seed on boot. Development-only: a sandbox payment gateway
// (all-zeros merchant id) and a demo marketplace (nurses/variants/search rows, customers/patients) so the
// real-path screens aren't empty. Neither belongs in a deployed DB — both are idempotent.
await app.ApplyMigrationsAsync();
await app.SeedDefaultUsersAsync();
await app.SeedPaymentGatewaysAsync();
await app.SeedDemoWorldAsync();
}
else
{
// Deployed: DDL is the separate `migrate` step above. Boot only *checks* the schema is current (fail fast
// on a pending migration) and seeds idempotent runtime data (roles + any configured break-glass admin).
await app.EnsureSchemaUpToDateAsync();
await app.SeedDefaultUsersAsync();
}
}
if (app.Environment.IsDevelopment())
@@ -33,7 +33,14 @@ internal sealed class GeneratePayoutBatchCommandHandler(
public async ValueTask<OperationResult<GeneratePayoutBatchResult>> Handle(
GeneratePayoutBatchCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } adminId)
// A scheduled (system-initiated) run has no human initiator; the HTTP path still requires an authenticated
// admin and records their id. SystemInitiated can only be set in-process by the scheduler.
int? initiatedByAdminId;
if (request.SystemInitiated)
initiatedByAdminId = null;
else if (currentUser.UserId is { } adminId)
initiatedByAdminId = adminId;
else
return OperationResult<GeneratePayoutBatchResult>.UnauthorizedResult("Not authenticated.");
var now = dateTimeProvider.UtcNow.UtcDateTime;
@@ -57,7 +64,7 @@ internal sealed class GeneratePayoutBatchCommandHandler(
PeriodStart = request.PeriodStart,
PeriodEnd = periodEnd,
ProcessingDate = processingDate,
InitiatedByAdminId = adminId
InitiatedByAdminId = initiatedByAdminId
};
var nurseIds = eligible.Select(e => e.NurseId).Distinct().ToList();
@@ -9,4 +9,13 @@ namespace Baya.Application.Features.Payouts.Commands.GeneratePayoutBatch;
/// clawbacks, snapshotting the verified primary IBAN, linking each booking under the UNIQUE guard). Returns the
/// draft batch + payouts for admin preview; no money moves until <c>process</c>.</summary>
public record GeneratePayoutBatchCommand(DateOnly PeriodStart, DateOnly PeriodEnd)
: IRequest<OperationResult<GeneratePayoutBatchResult>>;
: IRequest<OperationResult<GeneratePayoutBatchResult>>
{
/// <summary>
/// True only when the in-process scheduler runs the weekly generation unattended (refinement-phase-7): the
/// batch is recorded with a null initiator instead of requiring an authenticated admin. The HTTP path always
/// leaves this <c>false</c> — <c>AdminPayoutsController</c> neutralizes any request-supplied value — so it can
/// never be set by an API caller.
/// </summary>
public bool SystemInitiated { get; init; }
}
@@ -27,7 +27,7 @@ public record PayoutBatchDto(
string TotalAmount,
int PayoutCount,
string Status,
int InitiatedByAdminId,
int? InitiatedByAdminId,
DateTime? ProcessedAt,
string? FailureNotes,
DateTimeOffset CreatedAt);
@@ -35,8 +35,9 @@ public class NursePayoutBatch : BaseEntity<long>, IAuditable
/// <summary>Guarded — mutated only through <see cref="TransitionTo"/> so every write goes through the machine.</summary>
public string Status { get; private set; } = PayoutBatchStatus.Draft;
/// <summary>The admin who initiated the run (FK <c>users</c>). A future cron sets its own service id.</summary>
public int InitiatedByAdminId { get; set; }
/// <summary>The admin who initiated the run (FK <c>users</c>), or <c>null</c> for a system-initiated
/// (scheduled/unattended) batch — the weekly cron has no human initiator (refinement-phase-7).</summary>
public int? InitiatedByAdminId { get; set; }
public DateTime? ProcessedAt { get; private set; }
@@ -25,7 +25,8 @@ internal sealed class NursePayoutBatchConfig : IEntityTypeConfiguration<NursePay
builder.HasMany(b => b.Payouts).WithOne(p => p.Batch).HasForeignKey(p => p.BatchId).IsRequired();
builder.HasOne<User>().WithMany().HasForeignKey(b => b.InitiatedByAdminId).IsRequired();
// Nullable: a system-initiated (scheduled) batch has no admin initiator (refinement-phase-7).
builder.HasOne<User>().WithMany().HasForeignKey(b => b.InitiatedByAdminId).IsRequired(false);
builder.HasQueryFilter(b => b.DeletedAt == null);
}
@@ -0,0 +1,67 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Baya.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class RefinementPhase7SystemPayoutBatch : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_NursePayoutBatches_Users_InitiatedByAdminId",
schema: "payouts",
table: "NursePayoutBatches");
migrationBuilder.AlterColumn<int>(
name: "InitiatedByAdminId",
schema: "payouts",
table: "NursePayoutBatches",
type: "int",
nullable: true,
oldClrType: typeof(int),
oldType: "int");
migrationBuilder.AddForeignKey(
name: "FK_NursePayoutBatches_Users_InitiatedByAdminId",
schema: "payouts",
table: "NursePayoutBatches",
column: "InitiatedByAdminId",
principalSchema: "usr",
principalTable: "Users",
principalColumn: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_NursePayoutBatches_Users_InitiatedByAdminId",
schema: "payouts",
table: "NursePayoutBatches");
migrationBuilder.AlterColumn<int>(
name: "InitiatedByAdminId",
schema: "payouts",
table: "NursePayoutBatches",
type: "int",
nullable: false,
defaultValue: 0,
oldClrType: typeof(int),
oldType: "int",
oldNullable: true);
migrationBuilder.AddForeignKey(
name: "FK_NursePayoutBatches_Users_InitiatedByAdminId",
schema: "payouts",
table: "NursePayoutBatches",
column: "InitiatedByAdminId",
principalSchema: "usr",
principalTable: "Users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
}
}
}
@@ -3638,7 +3638,7 @@ namespace Baya.Infrastructure.Persistence.Migrations
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<int>("InitiatedByAdminId")
b.Property<int?>("InitiatedByAdminId")
.HasColumnType("int");
b.Property<DateTimeOffset?>("ModifiedAt")
@@ -5610,9 +5610,7 @@ namespace Baya.Infrastructure.Persistence.Migrations
{
b.HasOne("Baya.Domain.Entities.User.User", null)
.WithMany()
.HasForeignKey("InitiatedByAdminId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
.HasForeignKey("InitiatedByAdminId");
});
modelBuilder.Entity("Baya.Domain.Entities.Payouts.NursePayoutBookingLink", b =>
@@ -13,11 +13,12 @@ using Baya.Infrastructure.Persistence.Interceptors;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Baya.Infrastructure.Persistence.Services.Analytics;
using Baya.Infrastructure.Persistence.Services.Audit;
using Baya.Infrastructure.Persistence.Services.Booking;
using Baya.Infrastructure.Persistence.Services.Configuration;
using Baya.Infrastructure.Persistence.Services.Holidays;
using Baya.Infrastructure.Persistence.Services.Notifications;
using Baya.Infrastructure.Persistence.Services.Payments;
using Baya.Infrastructure.Persistence.Services.Scheduling;
using Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
using Baya.Infrastructure.Persistence.Services.Search;
using Baya.Infrastructure.Persistence.Services.Seeding;
using Baya.Infrastructure.Persistence.Services.SupportAlerts;
@@ -60,12 +61,16 @@ public static class ServiceCollectionExtensions
// supersedes the interim NursePayoutStatusService (dispute-window derivation); the refund fork is unchanged.
services.AddScoped<INursePayoutStatus, NursePayoutLinkStatusService>();
// Retention job seam (mock = in-process interval runner; real Hangfire/Quartz deferred).
services.AddHostedService<NotificationRetentionHostedService>();
// Booking-request expiry sweep (same in-process interval-runner seam): auto-expires stale
// pending/awaiting-payment requests. Also reachable via the admin manual-trigger endpoint.
services.AddHostedService<BookingRequestExpiryHostedService>();
// Unattended operation (refinement-phase-7). One in-process scheduler drives every IRecurringJob on its
// own cadence — the two re-homed sweeps plus the previously admin-manual crons, each reading its seeded
// cadence key. No new infra: SQL Server stays the only external dependency (Hangfire/Quartz + Redis are the
// documented scale-out gate for >1 instance). Phase 8 adds the Moadian reconciliation job the same way.
services.AddSingleton<IRecurringJob, BookingRequestExpiryJob>();
services.AddSingleton<IRecurringJob, NotificationRetentionJob>();
services.AddSingleton<IRecurringJob, CredentialExpiryScanJob>();
services.AddSingleton<IRecurringJob, NoShowSweepJob>();
services.AddSingleton<IRecurringJob, WeeklyPayoutGenerationJob>();
services.AddHostedService<RecurringJobSchedulerHostedService>();
// Search (backend-phase-7). The index maintainer keeps nurse_search_index consistent inline inside
// each source write's unit of work. The INurseSearch backend is config-selected — SQL is the real
@@ -97,6 +102,25 @@ public static class ServiceCollectionExtensions
await context.Database.MigrateAsync();
}
/// <summary>
/// Boot-time schema <b>check</b> (refinement-phase-7) for deployed environments: applying migrations is a
/// separate deploy step (the <c>migrate</c> one-shot / a CI <c>dotnet ef database update</c>), so concurrent
/// multi-instance start-ups never race on DDL and the app login needs no permanent DDL rights. If any
/// migration is pending, fail fast with a clear message rather than starting against a stale schema.
/// </summary>
public static async Task EnsureSchemaUpToDateAsync(this WebApplication app)
{
await using var scope = app.Services.CreateAsyncScope();
var context = scope.ServiceProvider.GetService<ApplicationDbContext>()
?? throw new Exception("Database Context Not Found");
var pending = (await context.Database.GetPendingMigrationsAsync()).ToList();
if (pending.Count > 0)
throw new InvalidOperationException(
$"Database schema is not up to date — {pending.Count} migration(s) pending: {string.Join(", ", pending)}. " +
"Run the deploy-time migration step (`dotnet run -- migrate`, or `dotnet ef database update` in CI) before starting the API.");
}
/// <summary>
/// Idempotently seeds one active <c>standard</c> payment gateway so the b10 card rail has a selectable
/// provider out of the box. <c>config_json</c> is encrypted at rest by the EF converter on save (so it
@@ -1,53 +0,0 @@
#nullable enable
using Baya.Application.Features.Booking.Commands.ExpireBookingRequests;
using Mediator;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.Persistence.Services.Booking;
/// <summary>
/// The recurring expiry sweep for <c>booking_requests</c> (reuses the b1 in-process interval-runner seam;
/// real Hangfire/Quartz is deferred). Each tick sends <see cref="ExpireBookingRequestsCommand"/>, which
/// transitions stale rows (<c>expired_no_response</c> / <c>payment_deadline_expired</c>) in bounded,
/// idempotent batches. The interval is short because the payment window is only 30 minutes; there is no b1
/// interval config key for booking expiry, so it is a documented constant.
/// </summary>
internal sealed class BookingRequestExpiryHostedService(
IServiceScopeFactory scopeFactory,
ILogger<BookingRequestExpiryHostedService> logger) : BackgroundService
{
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(1);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await SweepSafely(stoppingToken);
using var timer = new PeriodicTimer(Interval);
while (await timer.WaitForNextTickAsync(stoppingToken))
await SweepSafely(stoppingToken);
}
private async Task SweepSafely(CancellationToken cancellationToken)
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var sender = scope.ServiceProvider.GetRequiredService<ISender>();
var result = await sender.Send(new ExpireBookingRequestsCommand(), cancellationToken);
if (result.IsSuccess && result.Result is { } counts && (counts.ExpiredNoResponse > 0 || counts.PaymentDeadlineExpired > 0))
logger.LogInformation(
"Booking-request expiry swept {NoResponse} expired-no-response and {PaymentExpired} payment-window-expired requests",
counts.ExpiredNoResponse, counts.PaymentDeadlineExpired);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Host is shutting down — expected, don't log as an error.
}
catch (Exception ex)
{
logger.LogError(ex, "Booking-request expiry sweep failed");
}
}
}
@@ -1,50 +0,0 @@
#nullable enable
using Baya.Application.Contracts.Notifications;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.Persistence.Services.Notifications;
/// <summary>
/// The scheduling seam for the notification retention job (mock = an in-process interval runner). It
/// periodically hard-deletes read notifications older than the retention window; unread notifications are
/// never deleted. Real Hangfire/Quartz is deferred — swapping it in is a registration change here.
/// </summary>
internal sealed class NotificationRetentionHostedService(
IServiceScopeFactory scopeFactory,
ILogger<NotificationRetentionHostedService> logger) : BackgroundService
{
private const int RetentionDays = 90;
private static readonly TimeSpan Interval = TimeSpan.FromHours(24);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Run once at startup, then on the interval.
await PurgeSafely(stoppingToken);
using var timer = new PeriodicTimer(Interval);
while (await timer.WaitForNextTickAsync(stoppingToken))
await PurgeSafely(stoppingToken);
}
private async Task PurgeSafely(CancellationToken cancellationToken)
{
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var notifications = scope.ServiceProvider.GetRequiredService<INotificationService>();
var removed = await notifications.PurgeOldReadAsync(RetentionDays, cancellationToken);
if (removed > 0)
logger.LogInformation("Notification retention purged {Count} read notifications older than {Days}d", removed, RetentionDays);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Host is shutting down — expected, don't log as an error.
}
catch (Exception ex)
{
logger.LogError(ex, "Notification retention purge failed");
}
}
}
@@ -0,0 +1,30 @@
#nullable enable
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Baya.Infrastructure.Persistence.Services.Scheduling;
/// <summary>
/// One recurring background job driven by <see cref="RecurringJobSchedulerHostedService"/>. Each
/// implementation re-homes a previously admin-manual (or hardcoded-interval) sweep behind the same seam so
/// the platform runs itself: the scheduler owns the loop, the per-tick DI scope, the distributed lock, and
/// the error handling, while the job only says <em>how often</em> it runs and <em>what</em> one idempotent
/// run does. Every run must be idempotent — a scheduler retry (or a second instance once the lock is
/// Redis-backed) must never double-pay or double-post; the DB uniques/state-machines are the backstop.
/// </summary>
internal interface IRecurringJob
{
/// <summary>Stable identifier — used for the distributed-lock key (<c>scheduler:{Name}</c>) and log scope.</summary>
string Name { get; }
/// <summary>
/// Resolves this job's run interval, read fresh each tick (usually from a <c>platform_configs</c> cadence
/// key via <see cref="Baya.Application.Contracts.Configuration.IPlatformConfig"/>) so an admin cadence
/// change takes effect without a restart. <paramref name="services"/> is the per-tick scoped provider.
/// </summary>
ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken);
/// <summary>Executes one idempotent run within the provided per-tick DI scope.</summary>
ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken);
}
@@ -0,0 +1,36 @@
#nullable enable
using System;
using System.Threading;
using System.Threading.Tasks;
using Baya.Application.Features.Booking.Commands.ExpireBookingRequests;
using Mediator;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
/// <summary>
/// Auto-expires stale <c>booking_requests</c> (re-homed from the b8 <c>BookingRequestExpiryHostedService</c>).
/// Each run sends <see cref="ExpireBookingRequestsCommand"/>, which transitions stale rows
/// (<c>expired_no_response</c> / <c>payment_deadline_expired</c>) in bounded, idempotent batches. The interval
/// is a short constant because the payment window is only 30 minutes; there is no cadence config key for it.
/// Also reachable via the admin manual trigger.
/// </summary>
internal sealed class BookingRequestExpiryJob(ILogger<BookingRequestExpiryJob> logger) : IRecurringJob
{
public string Name => "booking_request_expiry";
public ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken)
=> ValueTask.FromResult(TimeSpan.FromMinutes(1));
public async ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var sender = services.GetRequiredService<ISender>();
var result = await sender.Send(new ExpireBookingRequestsCommand(), cancellationToken);
if (result.IsSuccess && result.Result is { } counts && (counts.ExpiredNoResponse > 0 || counts.PaymentDeadlineExpired > 0))
logger.LogInformation(
"Booking-request expiry swept {NoResponse} expired-no-response and {PaymentExpired} payment-window-expired requests",
counts.ExpiredNoResponse, counts.PaymentDeadlineExpired);
}
}
@@ -0,0 +1,40 @@
#nullable enable
using System;
using System.Threading;
using System.Threading.Tasks;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Features.Verification.Commands.ScanExpiringCredentials;
using Mediator;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
/// <summary>
/// Scans for lapsed time-limited verification steps (criminal-record especially), reverting each to expired,
/// raising a renewal alert/notification, and re-gating bookability. Previously admin-manual
/// (<c>admin_verifications/scan_expiring</c>) — now scheduled on the <c>verification_expiry_scan_cadence_hours</c>
/// cadence; the admin trigger remains an override. Sends <see cref="ScanExpiringCredentialsCommand"/> (idempotent).
/// </summary>
internal sealed class CredentialExpiryScanJob(ILogger<CredentialExpiryScanJob> logger) : IRecurringJob
{
public string Name => "verification_expiry_scan";
public async ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var config = services.GetRequiredService<IPlatformConfig>();
var hours = await config.GetConfig<int>("verification_expiry_scan_cadence_hours", cancellationToken);
return TimeSpan.FromHours(hours);
}
public async ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var sender = services.GetRequiredService<ISender>();
var result = await sender.Send(new ScanExpiringCredentialsCommand(), cancellationToken);
if (result.IsSuccess && result.Result is { } scan && scan.RevertedNurses > 0)
logger.LogInformation(
"Credential-expiry scan reverted {Nurses} nurse(s) across {Steps} expired step(s)",
scan.RevertedNurses, scan.ScannedSteps);
}
}
@@ -0,0 +1,38 @@
#nullable enable
using System;
using System.Threading;
using System.Threading.Tasks;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Features.Bookings.Commands.DetectNoShowSessions;
using Mediator;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
/// <summary>
/// Flags booking sessions whose scheduled start passed with no EVV check-in as no-shows. Previously admin-manual
/// (<c>admin_evv/detect_no_shows</c>) — now scheduled on the <c>no_show_scan_cadence_hours</c> cadence; the admin
/// trigger remains an override. Sends <see cref="DetectNoShowSessionsCommand"/> (idempotent — an already-flagged
/// session is not re-flagged).
/// </summary>
internal sealed class NoShowSweepJob(ILogger<NoShowSweepJob> logger) : IRecurringJob
{
public string Name => "no_show_sweep";
public async ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var config = services.GetRequiredService<IPlatformConfig>();
var hours = await config.GetConfig<int>("no_show_scan_cadence_hours", cancellationToken);
return TimeSpan.FromHours(hours);
}
public async ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var sender = services.GetRequiredService<ISender>();
var result = await sender.Send(new DetectNoShowSessionsCommand(), cancellationToken);
if (result.IsSuccess && result.Result is { Missed: > 0 } sweep)
logger.LogInformation("No-show sweep flagged {Missed} missed session(s)", sweep.Missed);
}
}
@@ -0,0 +1,33 @@
#nullable enable
using System;
using System.Threading;
using System.Threading.Tasks;
using Baya.Application.Contracts.Notifications;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
/// <summary>
/// Hard-deletes read notifications older than the retention window (re-homed from the b1
/// <c>NotificationRetentionHostedService</c>). Unread notifications are never deleted. Retention window and
/// cadence are documented constants (no config key).
/// </summary>
internal sealed class NotificationRetentionJob(ILogger<NotificationRetentionJob> logger) : IRecurringJob
{
private const int RetentionDays = 90;
public string Name => "notification_retention";
public ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken)
=> ValueTask.FromResult(TimeSpan.FromHours(24));
public async ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var notifications = services.GetRequiredService<INotificationService>();
var removed = await notifications.PurgeOldReadAsync(RetentionDays, cancellationToken);
if (removed > 0)
logger.LogInformation("Notification retention purged {Count} read notifications older than {Days}d", removed, RetentionDays);
}
}
@@ -0,0 +1,58 @@
#nullable enable
using System;
using System.Threading;
using System.Threading.Tasks;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Features.Payouts.Commands.GeneratePayoutBatch;
using Mediator;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
/// <summary>
/// Generates the weekly nurse-payout batch (previously admin-manual — <c>nurse_payout_interval_days</c> was seeded
/// but nothing read it, so nurses were paid only when an operator clicked). Scheduled on that cadence, it opens a
/// <c>draft</c> batch over the trailing window; the admin generate trigger remains an override.
///
/// <para><b>Generation only — never money movement.</b> Per the phase's "keep processing human-approved until trust
/// is earned", this schedules <em>batch generation</em>; the irreversible <c>process</c> step stays an explicit
/// admin action. The batch is <b>system-initiated</b> (<see cref="GeneratePayoutBatchCommand.SystemInitiated"/> →
/// null initiator). A quiet week (no eligible bookings) returns a benign failure, logged at debug — not an error.
/// Re-running over an overlapping window is safe: the <c>nurse_payout_booking_links.booking_id</c> UNIQUE prevents
/// re-selecting an already-paid booking.</para>
/// </summary>
internal sealed class WeeklyPayoutGenerationJob(ILogger<WeeklyPayoutGenerationJob> logger) : IRecurringJob
{
public string Name => "weekly_payout_generation";
public async ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var config = services.GetRequiredService<IPlatformConfig>();
var days = await config.GetConfig<int>("nurse_payout_interval_days", cancellationToken);
return TimeSpan.FromDays(days);
}
public async ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var clock = services.GetRequiredService<IDateTimeProvider>();
var config = services.GetRequiredService<IPlatformConfig>();
var sender = services.GetRequiredService<ISender>();
var intervalDays = await config.GetConfig<int>("nurse_payout_interval_days", cancellationToken);
var periodEnd = DateOnly.FromDateTime(clock.UtcNow.UtcDateTime);
var periodStart = periodEnd.AddDays(-Math.Max(intervalDays, 1));
var result = await sender.Send(
new GeneratePayoutBatchCommand(periodStart, periodEnd) { SystemInitiated = true }, cancellationToken);
if (result.IsSuccess && result.Result is { } generated)
logger.LogInformation(
"Weekly payout generation opened a draft batch of {Count} payout(s) totalling {Total} IRR (awaiting admin process)",
generated.Batch.PayoutCount, generated.Batch.TotalAmount);
else
// Expected on a quiet week (no eligible bookings) — not an operational error.
logger.LogDebug("Weekly payout generation produced no batch this run (no payout-eligible bookings).");
}
}
@@ -0,0 +1,113 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Baya.Application.Contracts.Payments;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Baya.Infrastructure.Persistence.Services.Scheduling;
/// <summary>
/// The single in-process job scheduler (refinement-phase-7): it drives every registered <see cref="IRecurringJob"/>
/// on its own cadence, replacing the two hand-written <c>PeriodicTimer</c> hosted services and giving the four
/// previously admin-manual sweeps (credential-expiry scan, EVV no-show sweep, weekly payout-batch generation,
/// and — Phase 8 — the Moadian reconciliation poll) a schedule. It intentionally uses <b>no new infrastructure</b>:
/// SQL Server stays the only external dependency, so a single-instance MVP needs neither Hangfire/Quartz nor Redis.
///
/// <para><b>Multi-instance readiness.</b> Each tick runs under <see cref="IDistributedLock"/>
/// (<c>scheduler:{Name}</c>). Today that lock is in-process (a no-op across instances); the moment a second API
/// instance runs it becomes the scale-out gate — swapping the lock seam to Redis serializes ticks across nodes.
/// Because every job is idempotent and the DB uniques/state-machines are authoritative, even a double-run is safe.</para>
///
/// <para><b>Not started under the "Testing" environment</b> so integration tests (WebApplicationFactory over
/// in-memory SQLite) stay deterministic — no background sweep mutates rows mid-assertion. Each job's underlying
/// command/service is unit-tested directly instead.</para>
/// </summary>
internal sealed class RecurringJobSchedulerHostedService(
IServiceScopeFactory scopeFactory,
IEnumerable<IRecurringJob> jobs,
IDistributedLock distributedLock,
IHostEnvironment environment,
ILogger<RecurringJobSchedulerHostedService> logger) : BackgroundService
{
// Floor so a mis-set cadence key (0 or negative) can never turn a loop into a hot spin.
private static readonly TimeSpan MinInterval = TimeSpan.FromSeconds(10);
// Used when a job's interval resolution throws (e.g. the config store is briefly unreachable).
private static readonly TimeSpan FallbackInterval = TimeSpan.FromMinutes(5);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (environment.IsEnvironment("Testing"))
return;
var jobList = jobs.ToArray();
logger.LogInformation("Recurring job scheduler starting with {Count} job(s): {Jobs}",
jobList.Length, string.Join(", ", jobList.Select(j => j.Name)));
// One independent loop per job so their cadences don't couple. A crash in one loop never stops the others.
await Task.WhenAll(jobList.Select(job => RunJobLoopAsync(job, stoppingToken)));
}
private async Task RunJobLoopAsync(IRecurringJob job, CancellationToken stoppingToken)
{
// Run once at startup, then on the job's own (re-read) cadence.
while (!stoppingToken.IsCancellationRequested)
{
var interval = await TickAndResolveIntervalAsync(job, stoppingToken);
try
{
await Task.Delay(interval, stoppingToken);
}
catch (OperationCanceledException)
{
return; // Host is shutting down.
}
}
}
private async Task<TimeSpan> TickAndResolveIntervalAsync(IRecurringJob job, CancellationToken stoppingToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var services = scope.ServiceProvider;
await RunTickAsync(job, services, stoppingToken);
return await ResolveIntervalAsync(job, services, stoppingToken);
}
private async Task RunTickAsync(IRecurringJob job, IServiceProvider services, CancellationToken stoppingToken)
{
try
{
await using var handle = await distributedLock.AcquireAsync($"scheduler:{job.Name}", stoppingToken);
await job.RunAsync(services, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
// Host is shutting down — expected, don't log as an error.
}
catch (Exception ex)
{
// A failing tick must never kill the loop — the next tick retries on schedule.
logger.LogError(ex, "Recurring job {Job} failed", job.Name);
}
}
private async Task<TimeSpan> ResolveIntervalAsync(IRecurringJob job, IServiceProvider services, CancellationToken stoppingToken)
{
try
{
var interval = await job.GetIntervalAsync(services, stoppingToken);
return interval < MinInterval ? MinInterval : interval;
}
catch (Exception ex)
{
logger.LogError(ex, "Recurring job {Job} interval resolution failed; using fallback {Fallback}", job.Name, FallbackInterval);
return FallbackInterval;
}
}
}
@@ -0,0 +1,114 @@
using Baya.Application.Contracts.Payments;
using Baya.Infrastructure.Persistence.Services.Scheduling;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
namespace Baya.Test.Foundation.Scheduling;
/// <summary>
/// Orchestration tests for <c>RecurringJobSchedulerHostedService</c> (refinement-phase-7): it runs each job at
/// startup under the distributed lock, keeps sibling loops alive when one job throws, and stays dormant under the
/// Testing environment so integration tests are deterministic.
/// </summary>
public sealed class RecurringJobSchedulerTests
{
private sealed class FakeEnvironment(string environmentName) : IHostEnvironment
{
public string EnvironmentName { get; set; } = environmentName;
public string ApplicationName { get; set; } = "Baya.Test";
public string ContentRootPath { get; set; } = ".";
public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } = null!;
}
private sealed class CountingJob(string name, bool throws = false) : IRecurringJob
{
private int _runCount;
public int RunCount => Volatile.Read(ref _runCount);
public string Name => name;
// Long interval: run once at startup, then idle for the whole test window.
public ValueTask<TimeSpan> GetIntervalAsync(IServiceProvider services, CancellationToken cancellationToken)
=> ValueTask.FromResult(TimeSpan.FromHours(1));
public ValueTask RunAsync(IServiceProvider services, CancellationToken cancellationToken)
{
Interlocked.Increment(ref _runCount);
if (throws)
throw new InvalidOperationException("boom");
return ValueTask.CompletedTask;
}
}
private static (IServiceScopeFactory scopeFactory, IDistributedLock @lock) Fakes(out IDistributedLock recordingLock)
{
var scopeFactory = new ServiceCollection().BuildServiceProvider().GetRequiredService<IServiceScopeFactory>();
recordingLock = Substitute.For<IDistributedLock>();
recordingLock.AcquireAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new ValueTask<IAsyncDisposable>(Substitute.For<IAsyncDisposable>()));
return (scopeFactory, recordingLock);
}
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (condition())
return;
await Task.Delay(20);
}
}
[Fact]
public async Task RunsJobAtStartup_UnderPerJobLock()
{
var (scopeFactory, @lock) = Fakes(out var recordingLock);
var job = new CountingJob("fake_job");
var scheduler = new RecurringJobSchedulerHostedService(
scopeFactory, [job], @lock, new FakeEnvironment("Development"),
NullLogger<RecurringJobSchedulerHostedService>.Instance);
await scheduler.StartAsync(default);
await WaitUntilAsync(() => job.RunCount >= 1, TimeSpan.FromSeconds(3));
await scheduler.StopAsync(default);
Assert.True(job.RunCount >= 1);
await recordingLock.Received().AcquireAsync("scheduler:fake_job", Arg.Any<CancellationToken>());
}
[Fact]
public async Task OneJobThrowing_DoesNotStopSiblingJobs()
{
var (scopeFactory, @lock) = Fakes(out _);
var throwing = new CountingJob("throwing", throws: true);
var healthy = new CountingJob("healthy");
var scheduler = new RecurringJobSchedulerHostedService(
scopeFactory, [throwing, healthy], @lock, new FakeEnvironment("Development"),
NullLogger<RecurringJobSchedulerHostedService>.Instance);
await scheduler.StartAsync(default);
await WaitUntilAsync(() => healthy.RunCount >= 1, TimeSpan.FromSeconds(3));
await scheduler.StopAsync(default);
Assert.True(throwing.RunCount >= 1);
Assert.True(healthy.RunCount >= 1);
}
[Fact]
public async Task TestingEnvironment_KeepsSchedulerDormant()
{
var (scopeFactory, @lock) = Fakes(out _);
var job = new CountingJob("fake_job");
var scheduler = new RecurringJobSchedulerHostedService(
scopeFactory, [job], @lock, new FakeEnvironment("Testing"),
NullLogger<RecurringJobSchedulerHostedService>.Instance);
await scheduler.StartAsync(default);
await Task.Delay(200);
await scheduler.StopAsync(default);
Assert.Equal(0, job.RunCount);
}
}
@@ -0,0 +1,102 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Features.Bookings.Commands.DetectNoShowSessions;
using Baya.Application.Features.Payouts.Commands.GeneratePayoutBatch;
using Baya.Application.Features.Verification.Commands.ScanExpiringCredentials;
using Baya.Application.Models.Booking;
using Baya.Application.Models.Common;
using Baya.Application.Models.Payouts;
using Baya.Application.Models.Verification;
using Baya.Infrastructure.Persistence.Services.Scheduling.Jobs;
using Mediator;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
namespace Baya.Test.Foundation.Scheduling;
/// <summary>
/// Unit tests for the recurring jobs (refinement-phase-7): each reads its seeded cadence key and dispatches the
/// same idempotent command the admin manual trigger sends. No timers involved — pure job behaviour.
/// </summary>
public sealed class RecurringJobTests
{
private static IServiceProvider ProviderWith(params (Type Type, object Impl)[] services)
{
var provider = Substitute.For<IServiceProvider>();
foreach (var (type, impl) in services)
provider.GetService(type).Returns(impl);
return provider;
}
[Fact]
public async Task CredentialExpiryScan_ReadsCadenceKey_AndSendsScanCommand()
{
var config = Substitute.For<IPlatformConfig>();
config.GetConfig<int>("verification_expiry_scan_cadence_hours", Arg.Any<CancellationToken>())
.Returns(new ValueTask<int>(6));
var sender = Substitute.For<ISender>();
sender.Send(Arg.Any<ScanExpiringCredentialsCommand>(), Arg.Any<CancellationToken>())
.Returns(OperationResult<ScanExpiringResult>.SuccessResult(new ScanExpiringResult(0, 0)));
var job = new CredentialExpiryScanJob(NullLogger<CredentialExpiryScanJob>.Instance);
var provider = ProviderWith((typeof(IPlatformConfig), config), (typeof(ISender), sender));
var interval = await job.GetIntervalAsync(provider, default);
await job.RunAsync(provider, default);
Assert.Equal(TimeSpan.FromHours(6), interval);
await sender.Received(1).Send(Arg.Any<ScanExpiringCredentialsCommand>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task NoShowSweep_ReadsCadenceKey_AndSendsDetectCommand()
{
var config = Substitute.For<IPlatformConfig>();
config.GetConfig<int>("no_show_scan_cadence_hours", Arg.Any<CancellationToken>())
.Returns(new ValueTask<int>(1));
var sender = Substitute.For<ISender>();
sender.Send(Arg.Any<DetectNoShowSessionsCommand>(), Arg.Any<CancellationToken>())
.Returns(OperationResult<NoShowSweepResult>.SuccessResult(new NoShowSweepResult(0)));
var job = new NoShowSweepJob(NullLogger<NoShowSweepJob>.Instance);
var provider = ProviderWith((typeof(IPlatformConfig), config), (typeof(ISender), sender));
var interval = await job.GetIntervalAsync(provider, default);
await job.RunAsync(provider, default);
Assert.Equal(TimeSpan.FromHours(1), interval);
await sender.Received(1).Send(Arg.Any<DetectNoShowSessionsCommand>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task WeeklyPayoutGeneration_ReadsInterval_AndSendsSystemInitiatedBatchOverTrailingWindow()
{
var config = Substitute.For<IPlatformConfig>();
config.GetConfig<int>("nurse_payout_interval_days", Arg.Any<CancellationToken>())
.Returns(new ValueTask<int>(7));
var clock = Substitute.For<IDateTimeProvider>();
clock.UtcNow.Returns(new DateTimeOffset(2026, 7, 13, 9, 0, 0, TimeSpan.Zero));
var sender = Substitute.For<ISender>();
sender.Send(Arg.Any<GeneratePayoutBatchCommand>(), Arg.Any<CancellationToken>())
.Returns(OperationResult<GeneratePayoutBatchResult>.SuccessResult(
new GeneratePayoutBatchResult(
new PayoutBatchDto(1, new DateOnly(2026, 7, 6), new DateOnly(2026, 7, 13),
new DateOnly(2026, 7, 13), "0", 0, "draft", null, null, null, DateTimeOffset.UtcNow),
[], [])));
var job = new WeeklyPayoutGenerationJob(NullLogger<WeeklyPayoutGenerationJob>.Instance);
var provider = ProviderWith(
(typeof(IPlatformConfig), config), (typeof(IDateTimeProvider), clock), (typeof(ISender), sender));
var interval = await job.GetIntervalAsync(provider, default);
await job.RunAsync(provider, default);
Assert.Equal(TimeSpan.FromDays(7), interval);
await sender.Received(1).Send(
Arg.Is<GeneratePayoutBatchCommand>(c =>
c.SystemInitiated
&& c.PeriodEnd == new DateOnly(2026, 7, 13)
&& c.PeriodStart == new DateOnly(2026, 7, 6)),
Arg.Any<CancellationToken>());
}
}