Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ else
<section class="metric-grid">
<article class="metric-card">
<div class="metric-label">Runtime</div>
<div class="metric-value">4</div>
<div class="metric-detail">Authentication, file processing, transaction generation, and polling.</div>
<div class="metric-value">5</div>
<div class="metric-detail">Authentication, file processing, transaction generation, polling, and merchant scan.</div>
</article>
<article class="metric-card">
<div class="metric-label">Merchants</div>
Expand All @@ -64,6 +64,11 @@ else
<div class="metric-value">@snapshot.Options.FileStatusPolling.PollIntervalSeconds s</div>
<div class="metric-detail">Polling cadence for completion checks.</div>
</article>
<article class="metric-card">
<div class="metric-label">Merchant scan</div>
<div class="metric-value">@snapshot.Options.MerchantScanIntervalSeconds s</div>
<div class="metric-detail">How often the worker re-checks for new or changed merchants.</div>
</article>
<article class="metric-card">
<div class="metric-label">Transactions</div>
<div class="metric-value">@($"{snapshot.Options.TransactionGeneration.MinimumTransactionsPerContract}-{snapshot.Options.TransactionGeneration.MaximumTransactionsPerContract}")</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,10 @@ else
</label>
</div>
<div class="form-actions">
<button type="submit" name="Delete" value="true" class="secondary-button" onclick="return confirm('Remove this merchant from processing?');">Remove merchant</button>
<button type="submit" class="primary-button">Save merchant</button>
</div>
<p class="muted" style="margin-top: 12px;">Removing a merchant stops future scheduling only. If a run is already in progress, it will finish normally.</p>
</form>
}
</section>
Expand Down Expand Up @@ -241,6 +243,11 @@ else
return "Merchant saved.";
}

if (query.Contains("removed=1", StringComparison.OrdinalIgnoreCase))
{
return "Merchant removed.";
}

if (query.Contains("error=", StringComparison.OrdinalIgnoreCase))
{
var errorIndex = query.IndexOf("error=", StringComparison.OrdinalIgnoreCase);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ else
<div class="field-label">Polling</div>
<div class="field-value">@snapshot.Options.FileStatusPolling.PollIntervalSeconds seconds</div>
</div>
<div>
<div class="field-label">Merchant scan</div>
<div class="field-value">@snapshot.Options.MerchantScanIntervalSeconds seconds</div>
</div>
</div>
</section>

Expand Down Expand Up @@ -80,7 +84,12 @@ else
<span>Polling interval seconds</span>
<input name="PollIntervalSeconds" class="text-input" type="number" value="@snapshot.Options.FileStatusPolling.PollIntervalSeconds" />
</label>
<label>
<span>Merchant scan interval seconds</span>
<input name="MerchantScanIntervalSeconds" class="text-input" type="number" min="1" value="@snapshot.Options.MerchantScanIntervalSeconds" />
</label>
</div>
<p class="muted">Lower values make newly added or enabled merchants appear in the schedule faster. Higher values reduce how often the worker re-checks the configuration.</p>
<div class="form-actions">
<button type="submit" class="primary-button">Save runtime settings</button>
<a class="secondary-button" href="/ops/config">Back to configuration hub</a>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ public sealed class FileStatusPollingOptions {
public int PollIntervalSeconds { get; init; } = 30;

public TimeSpan GetPollInterval() => TimeSpan.FromSeconds(this.PollIntervalSeconds);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ private MerchantProcessingConfigurationSnapshot BuildBootstrapSnapshot()
var fileProcessing = await dbContext.MerchantProcessingFileProcessingRecords.AsNoTracking().FirstOrDefaultAsync(record => record.Id == 1, cancellationToken);
var transactionGeneration = await dbContext.MerchantProcessingTransactionGenerationRecords.AsNoTracking().FirstOrDefaultAsync(record => record.Id == 1, cancellationToken);
var fileStatusPolling = await dbContext.MerchantProcessingFileStatusPollingRecords.AsNoTracking().FirstOrDefaultAsync(record => record.Id == 1, cancellationToken);
var merchantScan = await dbContext.MerchantProcessingMerchantScanRecords.AsNoTracking().FirstOrDefaultAsync(record => record.Id == 1, cancellationToken);

var fileProfiles = await dbContext.MerchantProcessingFileProfileRecords.AsNoTracking()
.OrderBy(record => record.SortOrder)
Expand All @@ -135,13 +136,13 @@ private MerchantProcessingConfigurationSnapshot BuildBootstrapSnapshot()
.OrderBy(record => record.SortOrder)
.ToListAsync(cancellationToken);

if (authentication is null && fileProcessing is null && transactionGeneration is null && fileStatusPolling is null &&
if (authentication is null && fileProcessing is null && transactionGeneration is null && fileStatusPolling is null && merchantScan is null &&
fileProfiles.Count == 0 && contractDefinitions.Count == 0 && merchants.Count == 0)
{
return null;
}

if (authentication is null || fileProcessing is null || transactionGeneration is null || fileStatusPolling is null)
if (authentication is null || fileProcessing is null || transactionGeneration is null || fileStatusPolling is null || merchantScan is null)
{
return null;
}
Expand Down Expand Up @@ -173,7 +174,8 @@ private MerchantProcessingConfigurationSnapshot BuildBootstrapSnapshot()
FileStatusPolling = new FileStatusPollingOptions
{
PollIntervalSeconds = fileStatusPolling.PollIntervalSeconds
}
},
MerchantScanIntervalSeconds = merchantScan.MerchantScanIntervalSeconds
};

options.FileProfiles.AddRange(fileProfiles.Select(record =>
Expand Down Expand Up @@ -271,6 +273,7 @@ private async Task<bool> HasNormalizedConfigurationAsync(MerchantFileProcessorDb
await dbContext.MerchantProcessingFileProcessingRecords.AnyAsync(record => record.Id == 1, cancellationToken) ||
await dbContext.MerchantProcessingTransactionGenerationRecords.AnyAsync(record => record.Id == 1, cancellationToken) ||
await dbContext.MerchantProcessingFileStatusPollingRecords.AnyAsync(record => record.Id == 1, cancellationToken) ||
await dbContext.MerchantProcessingMerchantScanRecords.AnyAsync(record => record.Id == 1, cancellationToken) ||
await dbContext.MerchantProcessingFileProfileRecords.AnyAsync(cancellationToken) ||
await dbContext.MerchantProcessingContractDefinitionRecords.AnyAsync(cancellationToken) ||
await dbContext.MerchantProcessingMerchantRecords.AnyAsync(cancellationToken);
Expand Down Expand Up @@ -358,6 +361,13 @@ private static async Task PersistSingletonsAsync(
UpdatedUtc = updatedUtc
});

dbContext.MerchantProcessingMerchantScanRecords.Add(new MerchantProcessingMerchantScanRecord
{
Id = 1,
MerchantScanIntervalSeconds = options.MerchantScanIntervalSeconds,
UpdatedUtc = updatedUtc
});

await dbContext.SaveChangesAsync(cancellationToken);
}

Expand Down Expand Up @@ -514,6 +524,7 @@ private static async Task ClearConfigurationTablesAsync(MerchantFileProcessorDbC
await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM MerchantProcessingFileProcessingRecords;", cancellationToken);
await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM MerchantProcessingTransactionGenerationRecords;", cancellationToken);
await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM MerchantProcessingFileStatusPollingRecords;", cancellationToken);
await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM MerchantProcessingMerchantScanRecords;", cancellationToken);
}

private static DateTimeOffset MaxUtc(params IEnumerable<DateTimeOffset>?[] sources)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ public sealed class MerchantProcessingOptions {

public FileStatusPollingOptions FileStatusPolling { get; init; } = new();

public int MerchantScanIntervalSeconds { get; init; } = 5;

public List<ContractDefinitionOptions> ContractDefinitions { get; init; } = [];

public List<FileProfileOptions> FileProfiles { get; init; } = [];

public List<MerchantOptions> Merchants { get; init; } = [];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ public static bool Validate(MerchantProcessingOptions options) {
return false;
}

if (options.MerchantScanIntervalSeconds <= 0) {
return false;
}

if (options.ContractDefinitions.Count == 0) {
return false;
}
Expand Down Expand Up @@ -78,4 +82,4 @@ private static bool AreFieldsValid(IEnumerable<FileFieldOptions> fields) {

return true;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ public sealed class MerchantFileProcessorDbContext(DbContextOptions<MerchantFile

public DbSet<MerchantProcessingFileStatusPollingRecord> MerchantProcessingFileStatusPollingRecords => this.Set<MerchantProcessingFileStatusPollingRecord>();

public DbSet<MerchantProcessingMerchantScanRecord> MerchantProcessingMerchantScanRecords => this.Set<MerchantProcessingMerchantScanRecord>();

public DbSet<MerchantProcessingFileProfileRecord> MerchantProcessingFileProfileRecords => this.Set<MerchantProcessingFileProfileRecord>();

public DbSet<MerchantProcessingFileProfileFieldRecord> MerchantProcessingFileProfileFieldRecords => this.Set<MerchantProcessingFileProfileFieldRecord>();
Expand Down Expand Up @@ -104,6 +106,13 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
entity.Property(record => record.UpdatedUtc);
});

modelBuilder.Entity<MerchantProcessingMerchantScanRecord>(entity =>
{
entity.ToTable("MerchantProcessingMerchantScanRecords");
entity.HasKey(record => record.Id);
entity.Property(record => record.UpdatedUtc);
});

modelBuilder.Entity<MerchantProcessingFileProfileRecord>(entity =>
{
entity.ToTable("MerchantProcessingFileProfiles");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ public sealed class MerchantProcessingFileStatusPollingRecord
public DateTimeOffset UpdatedUtc { get; set; }
}

public sealed class MerchantProcessingMerchantScanRecord
{
public int Id { get; set; }

public int MerchantScanIntervalSeconds { get; set; }

public DateTimeOffset UpdatedUtc { get; set; }
}

public sealed class MerchantProcessingFileProfileRecord
{
public int Id { get; set; }
Expand Down
10 changes: 10 additions & 0 deletions TransactionProcessing.MerchantFileProcessor/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,16 @@ UpdatedUtc TEXT NOT NULL
""",
cancellationToken);

await dbContext.Database.ExecuteSqlRawAsync(
"""
CREATE TABLE IF NOT EXISTS MerchantProcessingMerchantScanRecords (
Id INTEGER NOT NULL CONSTRAINT PK_MerchantProcessingMerchantScanRecords PRIMARY KEY,
MerchantScanIntervalSeconds INTEGER NOT NULL,
UpdatedUtc TEXT NOT NULL
);
""",
cancellationToken);

await dbContext.Database.ExecuteSqlRawAsync(
"""
CREATE TABLE IF NOT EXISTS MerchantProcessingFileProfiles (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ public static IEndpointRouteBuilder MapConfigurationManagementEndpoints(this IEn
{
PollIntervalSeconds = ParseInt(form["PollIntervalSeconds"], 30)
},
MerchantScanIntervalSeconds = ParseInt(form["MerchantScanIntervalSeconds"], 5),
ContractDefinitions = options.ContractDefinitions,
FileProfiles = options.FileProfiles,
Merchants = options.Merchants
Expand Down Expand Up @@ -70,6 +71,26 @@ public static IEndpointRouteBuilder MapConfigurationManagementEndpoints(this IEn
var merchantIndex = options.Merchants.FindIndex(entry =>
string.Equals(entry.MerchantId, originalMerchantId ?? merchantId, StringComparison.OrdinalIgnoreCase));

if (ParseBool(form["Delete"]))
{
if (merchantIndex < 0)
{
return Results.Redirect($"{returnUrl}?error={Uri.EscapeDataString("Merchant not found.")}");
}

options.Merchants.RemoveAt(merchantIndex);

try
{
await configurationStore.SaveAsync(options, cancellationToken);
return Results.Redirect("/ops/config/merchants?removed=1");
}
catch (Exception ex)
{
return Results.Redirect($"{returnUrl}?error={Uri.EscapeDataString(ex.Message)}");
}
}

var merchant = new MerchantOptions
{
Name = GetRequired(form, "Name"),
Expand Down Expand Up @@ -308,6 +329,7 @@ private static MerchantProcessingOptions Clone(MerchantProcessingOptions options
{
PollIntervalSeconds = options.FileStatusPolling.PollIntervalSeconds
},
MerchantScanIntervalSeconds = options.MerchantScanIntervalSeconds,
ContractDefinitions = options.ContractDefinitions
.Select(contract => new ContractDefinitionOptions
{
Expand Down
25 changes: 23 additions & 2 deletions TransactionProcessing.MerchantFileProcessor/Worker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,16 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
while (!stoppingToken.IsCancellationRequested) {
MerchantProcessingOptions options = configurationState.Current;
MerchantOptions[] enabledMerchants = options.Merchants.Where(merchant => merchant.Enabled).ToArray();
TimeSpan scanInterval = TimeSpan.FromSeconds(Math.Max(1, options.MerchantScanIntervalSeconds));

if (enabledMerchants.Length == 0) {
logger.LogWarning("No enabled merchants are configured for processing. Waiting before checking again.");
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
try {
await Task.Delay(scanInterval, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) {
break;
}
continue;
}

Expand All @@ -29,8 +35,23 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
LocalLogger.Context merchantLogger = LocalLogger.For(scheduledMerchant.Merchant.MerchantId);
merchantLogger.LogInformation($"Next merchant processing run scheduled at {nextRun:O} for slot {scheduledMerchant.ScheduledRunUtc:O}");

if (delay > scanInterval) {
try {
await Task.Delay(scanInterval, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) {
break;
}
continue;
}

if (delay > TimeSpan.Zero) {
await Task.Delay(delay, stoppingToken);
try {
await Task.Delay(delay, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) {
break;
}
}

if (stoppingToken.IsCancellationRequested) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"FileStatusPolling": {
"PollIntervalSeconds": 30
},
"MerchantScanIntervalSeconds": 5,
"ContractDefinitions": [
{
"ContractId": "052A6CA5-47FC-4844-ADCA-7D44EFD8F21C",
Expand Down
2 changes: 2 additions & 0 deletions TransactionProcessing.MerchantPos/Pages/Config.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@
<div class="field"><label asp-for="Config.ClientSecret"></label><input asp-for="Config.ClientSecret" type="text" autocomplete="off" spellcheck="false" /></div>
<div class="field"><label asp-for="Config.ServiceClientId"></label><input asp-for="Config.ServiceClientId" /></div>
<div class="field"><label asp-for="Config.ServiceClientSecret"></label><input asp-for="Config.ServiceClientSecret" type="text" autocomplete="off" spellcheck="false" /></div>
<div class="field"><label asp-for="Config.MerchantScanIntervalSeconds"></label><input asp-for="Config.MerchantScanIntervalSeconds" type="number" min="1" step="1" /></div>
</div>
<p class="page-note" style="margin-top: 12px;">Smaller values check for added, enabled, disabled, or removed merchants more quickly. Larger values reduce how often the service re-scans the database.</p>
</section>

<section class="group">
Expand Down
3 changes: 3 additions & 0 deletions TransactionProcessing.MerchantPos/Pages/Config.cshtml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public async Task<IActionResult> OnPostSaveAsync()
settings.WorkerSettings.ClientSecret = UnmaskSecret(Config.ClientSecret, settings.WorkerSettings.ClientSecret);
settings.WorkerSettings.ServiceClientId = Config.ServiceClientId;
settings.WorkerSettings.ServiceClientSecret = UnmaskSecret(Config.ServiceClientSecret, settings.WorkerSettings.ServiceClientSecret);
settings.WorkerSettings.MerchantScanIntervalSeconds = Math.Max(1, Config.MerchantScanIntervalSeconds);

settings.ConnectionStrings.MerchantDb = MerchantPosSettingsStore.NormalizePath(Config.MerchantDb);
settings.ConnectionStrings.SettingsDb = MerchantPosSettingsStore.NormalizePath(Config.SettingsDb);
Expand All @@ -66,6 +67,7 @@ public sealed class ConfigInput
public string ClientSecret { get; set; } = string.Empty;
public string ServiceClientId { get; set; } = string.Empty;
public string ServiceClientSecret { get; set; } = string.Empty;
public int MerchantScanIntervalSeconds { get; set; } = 5;
public string MerchantDb { get; set; } = string.Empty;
public string SettingsDb { get; set; } = string.Empty;

Expand All @@ -80,6 +82,7 @@ public static ConfigInput FromSnapshot(MerchantPosSettingsSnapshot snapshot)
ClientSecret = snapshot.WorkerSettings.ClientSecret,
ServiceClientId = snapshot.WorkerSettings.ServiceClientId,
ServiceClientSecret = snapshot.WorkerSettings.ServiceClientSecret,
MerchantScanIntervalSeconds = snapshot.WorkerSettings.MerchantScanIntervalSeconds,
MerchantDb = MerchantPosSettingsStore.NormalizePath(snapshot.ConnectionStrings.MerchantDb),
SettingsDb = MerchantPosSettingsStore.NormalizePath(snapshot.ConnectionStrings.SettingsDb)
};
Expand Down
Loading
Loading