diff --git a/TransactionProcessing.MerchantFileProcessor/App.razor b/TransactionProcessing.MerchantFileProcessor/App.razor new file mode 100644 index 0000000..1dd0909 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/App.razor @@ -0,0 +1,7 @@ + + + + + + + diff --git a/TransactionProcessing.MerchantFileProcessor/Clients/FileProcessingClient.cs b/TransactionProcessing.MerchantFileProcessor/Clients/FileProcessingClient.cs index 1ac585f..5f20907 100644 --- a/TransactionProcessing.MerchantFileProcessor/Clients/FileProcessingClient.cs +++ b/TransactionProcessing.MerchantFileProcessor/Clients/FileProcessingClient.cs @@ -22,19 +22,26 @@ Task> GetFileStatus(string accessToken, CancellationToken cancellationToken); } -public sealed class FileProcessingClient(IFileProcessorClient fileProcessorClient, MerchantProcessingOptions options) : IFileProcessingClient { +public sealed class FileProcessingClient( + IFileProcessorClient fileProcessorClient, + IMerchantProcessingConfigurationState configurationState) : IFileProcessingClient +{ public async Task> Upload(MerchantOptions merchant, ContractOptions contract, string accessToken, GeneratedFile file, - CancellationToken cancellationToken) { + CancellationToken cancellationToken) + { + var options = configurationState.Current; FileProfileOptions? fileProfile = options.FileProfiles.FirstOrDefault(profile => profile.FileProfileId.Equals(file.FileProfileId, StringComparison.OrdinalIgnoreCase)); - if (fileProfile is null) { + if (fileProfile is null) + { return new Result { IsSuccess = false, Status = ResultStatus.Failure, Message = $"Generated file references unknown file profile '{file.FileProfileId}'." }; } - UploadFileRequest request = new UploadFileRequest { + UploadFileRequest request = new UploadFileRequest + { EstateId = merchant.GetEstateGuid(), MerchantId = merchant.GetMerchantGuid(), UserId = options.FileProcessing.GetUserGuid(), @@ -44,7 +51,8 @@ public async Task> Upload(MerchantOptions merchant, Result? result = await fileProcessorClient.UploadFile(accessToken, file.FileName, file.Content, request, cancellationToken); - if (result.IsFailed) { + if (result.IsFailed) + { return new Result { IsSuccess = false, Status = ResultStatus.Failure, Message = $"File processor client failed to upload file '{file.FileName}'." }; } @@ -54,10 +62,12 @@ public async Task> Upload(MerchantOptions merchant, public async Task> GetFileStatus(string accessToken, Guid estateId, Guid fileId, - CancellationToken cancellationToken) { + CancellationToken cancellationToken) + { Result? result = await fileProcessorClient.GetFile(accessToken, estateId, fileId, cancellationToken); - if (result.IsFailed || result.Data is null) { + if (result.IsFailed || result.Data is null) + { return new Result { IsSuccess = false, Status = ResultStatus.Failure, Message = $"File processor client failed to retrieve status for file '{fileId}'." }; } @@ -69,13 +79,16 @@ public async Task> GetFileStatus(string acc private static FileProcessingLineStatusSnapshot MapLineStatus(FileLine line) => new(line.LineNumber, line.LineData, line.ProcessingResult.ToString(), string.IsNullOrWhiteSpace(line.RejectionReason) ? null : line.RejectionReason, line.TransactionId == Guid.Empty ? null : line.TransactionId); - private static bool AreAllLinesResolved(IEnumerable lines) { + private static bool AreAllLinesResolved(IEnumerable lines) + { bool hasLines = false; - foreach (FileProcessingLineStatusSnapshot line in lines) { + foreach (FileProcessingLineStatusSnapshot line in lines) + { hasLines = true; - if (line.ProcessingStatus.Equals(FileLineStatuses.Unknown, StringComparison.OrdinalIgnoreCase) || line.ProcessingStatus.Equals(FileLineStatuses.NotProcessed, StringComparison.OrdinalIgnoreCase)) { + if (line.ProcessingStatus.Equals(FileLineStatuses.Unknown, StringComparison.OrdinalIgnoreCase) || line.ProcessingStatus.Equals(FileLineStatuses.NotProcessed, StringComparison.OrdinalIgnoreCase)) + { return false; } } diff --git a/TransactionProcessing.MerchantFileProcessor/Clients/SharedAccessTokenProvider.cs b/TransactionProcessing.MerchantFileProcessor/Clients/SharedAccessTokenProvider.cs index 58f70ce..8112e68 100644 --- a/TransactionProcessing.MerchantFileProcessor/Clients/SharedAccessTokenProvider.cs +++ b/TransactionProcessing.MerchantFileProcessor/Clients/SharedAccessTokenProvider.cs @@ -12,7 +12,7 @@ public interface IAccessTokenProvider public sealed class SharedAccessTokenProvider( ISecurityServiceClient securityServiceClient, - MerchantProcessingOptions options) : IAccessTokenProvider + IMerchantProcessingConfigurationState configurationState) : IAccessTokenProvider { private readonly SemaphoreSlim refreshLock = new(1, 1); private CachedToken? currentToken; @@ -53,7 +53,7 @@ public async Task> GetAccessToken(CancellationToken cancella private async Task> RequestToken(CancellationToken cancellationToken) { - var authentication = options.Authentication; + var authentication = configurationState.Current.Authentication; var tokenResult = await securityServiceClient.GetToken( authentication.ClientId, authentication.ClientSecret, diff --git a/TransactionProcessing.MerchantFileProcessor/Components/Layout/MainLayout.razor b/TransactionProcessing.MerchantFileProcessor/Components/Layout/MainLayout.razor new file mode 100644 index 0000000..532e1f5 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Components/Layout/MainLayout.razor @@ -0,0 +1,96 @@ +@inherits LayoutComponentBase +@inject NavigationManager Navigation + +
+ + +
+
+
+
@CurrentAreaTitle
+

@CurrentPageTitle

+

@CurrentPageSummary

+
+ + +
+ + @Body +
+
+ +@code { + private string CurrentAreaTitle => IsStatusRoute ? "Status" : "Operations"; + + private string CurrentAreaSummary => IsStatusRoute + ? "Merchant rollups, file detail, and audit trail inspection." + : "Configuration, scheduling, and run monitoring."; + + private string CurrentPageTitle => Navigation.ToBaseRelativePath(Navigation.Uri).Trim('/') switch + { + "" or "ops" => "Merchant file processor overview", + "ops/config" => "Configuration hub", + "ops/config/runtime" => "Runtime settings", + "ops/config/merchants" => "Merchants", + "ops/config/file-profiles" => "File profiles", + "ops/runs" => "Run history", + "status" => "Merchant file activity", + _ when Navigation.ToBaseRelativePath(Navigation.Uri).StartsWith("status/", StringComparison.OrdinalIgnoreCase) => "Merchant file detail", + _ when Navigation.ToBaseRelativePath(Navigation.Uri).StartsWith("ops/config/file-profiles/", StringComparison.OrdinalIgnoreCase) => "File profile", + _ => "Merchant File Processor" + }; + + private string CurrentPageSummary => Navigation.ToBaseRelativePath(Navigation.Uri).Trim('/') switch + { + "" or "ops" => "Live configuration, scheduling, and processing visibility.", + "ops/config" => "Section-based configuration management.", + "ops/config/runtime" => "Authentication, file-processing, generation, and polling settings.", + "ops/config/merchants" => "Merchant schedules, estates, and enabled flags.", + "ops/config/file-profiles" => "Profile metadata and field tables.", + "ops/config/file-profiles/" => "Profile detail and field editing.", + "ops/runs" => "Recent processing execution history.", + "status" => "Merchant rollups and latest file activity.", + _ when Navigation.ToBaseRelativePath(Navigation.Uri).StartsWith("status/", StringComparison.OrdinalIgnoreCase) => "Line-level file drilldown and error details.", + _ when Navigation.ToBaseRelativePath(Navigation.Uri).StartsWith("ops/config/file-profiles/", StringComparison.OrdinalIgnoreCase) => "Profile metadata and field editing.", + _ => "Live operational control surface." + }; + + private bool IsStatusRoute => Navigation.ToBaseRelativePath(Navigation.Uri).StartsWith("status", StringComparison.OrdinalIgnoreCase); +} diff --git a/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsConfiguration.razor b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsConfiguration.razor new file mode 100644 index 0000000..df00311 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsConfiguration.razor @@ -0,0 +1,121 @@ +@page "/ops/config" +@inject IMerchantProcessingConfigurationStore ConfigurationStore + +Merchant File Processor - Configuration + +@if (!loaded) +{ +
+

Loading configuration data...

+
+} +else +{ +
+
+
+
Configuration hub
+

Relational configuration

+

The app now stores configuration in SQLite tables instead of a single JSON document. Use the section screens to manage runtime settings, merchants, file profiles, and contract mappings.

+
+
+
+
Updated
+
@snapshot.UpdatedUtc.ToString("u")
+
+
+
Merchants
+
@snapshot.Options.Merchants.Count
+
+
+
File profiles
+
@snapshot.Options.FileProfiles.Count
+
+
+
Contracts
+
@snapshot.Options.ContractDefinitions.Count
+
+
+
+ +
+
+
Runtime
+
4
+
Authentication, file processing, transaction generation, and polling.
+
+
+
Merchants
+
@snapshot.Options.Merchants.Count
+
Schedules, estates, and enabled flags.
+
+
+
File profiles
+
@snapshot.Options.FileProfiles.Count
+
Body, header, and trailer field tables.
+
+
+
Contracts
+
@snapshot.Options.ContractDefinitions.Count
+
Contract-to-profile mappings.
+
+
+
File status
+
@snapshot.Options.FileStatusPolling.PollIntervalSeconds s
+
Polling cadence for completion checks.
+
+
+
Transactions
+
@($"{snapshot.Options.TransactionGeneration.MinimumTransactionsPerContract}-{snapshot.Options.TransactionGeneration.MaximumTransactionsPerContract}")
+
Generated transaction range per contract.
+
+
+ +
+
+
+

Runtime settings

+

Authentication, file-processing, generation, and polling values.

+
+

Open runtime settings

+
+ +
+
+

Merchants

+

Schedule and estate definitions for each merchant.

+
+

Open merchants

+
+
+ +
+
+
+

File profiles

+

Profiles and their field tables for file generation.

+
+

Open file profiles

+
+ +
+
+

Contract mappings

+

Contract IDs mapped to file profiles.

+
+

Open contracts

+
+
+
+} + +@code { + private MerchantProcessingConfigurationSnapshot snapshot = new(new MerchantProcessingOptions(), string.Empty, DateTimeOffset.MinValue); + private bool loaded; + + protected override async Task OnInitializedAsync() + { + snapshot = await ConfigurationStore.GetCurrentSnapshotAsync(CancellationToken.None); + loaded = true; + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsContracts.razor b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsContracts.razor new file mode 100644 index 0000000..9db20e7 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsContracts.razor @@ -0,0 +1,210 @@ +@page "/ops/config/contracts" +@inject IMerchantProcessingConfigurationStore ConfigurationStore +@inject NavigationManager Navigation + +Merchant File Processor - Contracts + +@if (!loaded) +{ +
+

Loading contracts...

+
+} +else +{ +
+ @if (!string.IsNullOrWhiteSpace(statusMessage)) + { +
@statusMessage
+ } + +
+
+
Contracts
+

Contract mappings

+

Select a contract from the table to edit it in the detail pane.

+
+
+
+
Contracts
+
@snapshot.Options.ContractDefinitions.Count
+
+
+
File profiles
+
@snapshot.Options.FileProfiles.Count
+
+
+
Updated
+
@snapshot.UpdatedUtc.ToString("u")
+
+
+
+ +
+
+
+

Existing contracts

+

Select a row to edit it.

+
+ @if (snapshot.Options.ContractDefinitions.Count == 0) + { +
No contracts are configured yet.
+ } + else + { +
+ + + + + + + + + @foreach (var contract in snapshot.Options.ContractDefinitions) + { + var isSelected = string.Equals(contract.ContractId, selectedContractId, StringComparison.OrdinalIgnoreCase); + + + + + } + +
Contract IDFile profile ID
+ @contract.ContractId + @contract.FileProfileId
+
+ } +
+ +
+
+
+

@(selectedContract?.ContractId ?? "Contract details")

+

@(selectedContract is null ? "Select a contract on the left." : selectedContract.FileProfileId)

+
+ + @if (selectedContract is null) + { +
Pick a contract from the table to edit it here.
+ } + else + { +
+ + +
+ + +
+
+ +
+
+ } +
+ +
+
+

Add contract

+

Create a new contract-to-profile mapping.

+
+
+ + +
+ + +
+
+ + Back to configuration hub +
+
+
+
+
+
+} + +@code { + private MerchantProcessingConfigurationSnapshot snapshot = new(new MerchantProcessingOptions(), string.Empty, DateTimeOffset.MinValue); + private bool loaded; + private string? statusMessage; + private string selectedContractId = string.Empty; + + private ContractDefinitionOptions? selectedContract => snapshot.Options.ContractDefinitions.FirstOrDefault(contract => + contract.ContractId.Equals(selectedContractId, StringComparison.OrdinalIgnoreCase)); + + protected override async Task OnInitializedAsync() + { + snapshot = await ConfigurationStore.GetCurrentSnapshotAsync(CancellationToken.None); + selectedContractId = GetQueryValue("contractId") ?? snapshot.Options.ContractDefinitions.FirstOrDefault()?.ContractId ?? string.Empty; + statusMessage = ResolveStatusMessage(); + loaded = true; + } + + private string BuildSelectionHref(string contractId) => + $"/ops/config/contracts?contractId={Uri.EscapeDataString(contractId)}"; + + private string BuildReturnUrl(string contractId) => + BuildSelectionHref(contractId); + + private string? GetQueryValue(string key) + { + var query = Navigation.ToAbsoluteUri(Navigation.Uri).Query; + if (string.IsNullOrWhiteSpace(query)) + { + return null; + } + + query = query.TrimStart('?'); + foreach (var part in query.Split('&', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var pieces = part.Split('=', 2); + if (pieces.Length == 2 && pieces[0].Equals(key, StringComparison.OrdinalIgnoreCase)) + { + return Uri.UnescapeDataString(pieces[1]); + } + } + + return null; + } + + private string? ResolveStatusMessage() + { + var query = Navigation.ToAbsoluteUri(Navigation.Uri).Query; + + if (query.Contains("saved=1", StringComparison.OrdinalIgnoreCase)) + { + return "Contract saved."; + } + + if (query.Contains("error=", StringComparison.OrdinalIgnoreCase)) + { + var errorIndex = query.IndexOf("error=", StringComparison.OrdinalIgnoreCase); + var errorValue = errorIndex >= 0 ? query[(errorIndex + 6)..] : string.Empty; + var ampersandIndex = errorValue.IndexOf('&'); + if (ampersandIndex >= 0) + { + errorValue = errorValue[..ampersandIndex]; + } + + return string.IsNullOrWhiteSpace(errorValue) ? "Save failed." : Uri.UnescapeDataString(errorValue); + } + + return null; + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsFileProfile.razor b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsFileProfile.razor new file mode 100644 index 0000000..d4f3388 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsFileProfile.razor @@ -0,0 +1,307 @@ +@page "/ops/config/file-profiles/{FileProfileId}" +@inject IMerchantProcessingConfigurationStore ConfigurationStore +@inject NavigationManager Navigation + +Merchant File Processor - File Profile + +@if (!loaded) +{ +
+

Loading file profile...

+
+} +else if (profile is null) +{ +
+
+
+
File profile
+

Profile not found

+

The requested file profile does not exist.

+

← Back to file profiles

+
+
+
+} +else +{ +
+ @if (!string.IsNullOrWhiteSpace(statusMessage)) + { +
@statusMessage
+ } + +
+
+
File profile
+

@profile.FileProfileId

+

@profile.FileProcessorFileProfileId

+

Manage the file profile metadata and the three field tables that define the file layout.

+
+
+
+
Format
+
@profile.Format
+
+
+
Fields
+
@profile.Fields.Count
+
+
+
Updated
+
@snapshot.UpdatedUtc.ToString("u")
+
+
+
+ +
+
+ + +
+

Profile metadata

+

The profile ID stays as the row key; update the layout properties as needed.

+
+
+ + + + + + + + + + +
+
+ + Back to file profiles +
+
+
+ +
+
+
+

Body fields

+

These are the file columns used by the main file layout.

+
+ @RenderFieldSection("body", profile.Fields) +
+ +
+
+

Header fields

+

Header rows for delimited files.

+
+ @RenderFieldSection("header", profile.Delimited.HeaderFields) +
+
+ +
+
+

Trailer fields

+

Trailer rows for delimited files.

+
+ @RenderFieldSection("trailer", profile.Delimited.TrailerFields) +
+
+} + +@code { + [Parameter] + public string FileProfileId { get; set; } = string.Empty; + + private MerchantProcessingConfigurationSnapshot snapshot = new(new MerchantProcessingOptions(), string.Empty, DateTimeOffset.MinValue); + private FileProfileOptions? profile; + private bool loaded; + private string? statusMessage; + + protected override async Task OnInitializedAsync() + { + snapshot = await ConfigurationStore.GetCurrentSnapshotAsync(CancellationToken.None); + profile = snapshot.Options.FileProfiles.FirstOrDefault(entry => entry.FileProfileId.Equals(FileProfileId, StringComparison.OrdinalIgnoreCase)); + statusMessage = ResolveStatusMessage(); + loaded = true; + } + + private RenderFragment RenderFieldSection(string section, IReadOnlyList fields) => builder => + { + if (fields.Count == 0) + { + builder.OpenElement(0, "div"); + builder.AddAttribute(1, "class", "empty-state"); + builder.AddContent(2, "No fields are configured for this section yet."); + builder.CloseElement(); + } + + var sequence = 0; + foreach (var field in fields.Select((field, index) => new { field, index })) + { + builder.OpenElement(sequence++, "article"); + builder.AddAttribute(sequence++, "class", "field-card"); + + builder.OpenElement(sequence++, "form"); + builder.AddAttribute(sequence++, "method", "post"); + builder.AddAttribute(sequence++, "action", $"/api/configuration/file-profiles/{Uri.EscapeDataString(profile!.FileProfileId)}/fields/{section}"); + builder.AddAttribute(sequence++, "class", "config-form"); + + builder.OpenElement(sequence++, "input"); + builder.AddAttribute(sequence++, "type", "hidden"); + builder.AddAttribute(sequence++, "name", "ReturnUrl"); + builder.AddAttribute(sequence++, "value", $"/ops/config/file-profiles/{Uri.EscapeDataString(profile!.FileProfileId)}"); + builder.CloseElement(); + + builder.OpenElement(sequence++, "input"); + builder.AddAttribute(sequence++, "type", "hidden"); + builder.AddAttribute(sequence++, "name", "OriginalSortOrder"); + builder.AddAttribute(sequence++, "value", field.index); + builder.CloseElement(); + + builder.OpenElement(sequence++, "div"); + builder.AddAttribute(sequence++, "class", "form-grid"); + AddFieldInput(builder, ref sequence, "Name", field.field.Name); + AddFieldInput(builder, ref sequence, "Source", field.field.Source); + AddFieldInput(builder, ref sequence, "Format", field.field.Format); + AddFieldInput(builder, ref sequence, "Value", field.field.Value); + builder.CloseElement(); + + builder.OpenElement(sequence++, "div"); + builder.AddAttribute(sequence++, "class", "form-actions"); + builder.OpenElement(sequence++, "button"); + builder.AddAttribute(sequence++, "type", "submit"); + builder.AddAttribute(sequence++, "class", "primary-button"); + builder.AddContent(sequence++, "Save field"); + builder.CloseElement(); + builder.OpenElement(sequence++, "button"); + builder.AddAttribute(sequence++, "type", "submit"); + builder.AddAttribute(sequence++, "name", "Delete"); + builder.AddAttribute(sequence++, "value", "true"); + builder.AddAttribute(sequence++, "class", "secondary-button"); + builder.AddContent(sequence++, "Delete"); + builder.CloseElement(); + builder.CloseElement(); + + builder.CloseElement(); + builder.CloseElement(); + } + + builder.OpenElement(sequence++, "article"); + builder.AddAttribute(sequence++, "class", "field-card"); + builder.OpenElement(sequence++, "form"); + builder.AddAttribute(sequence++, "method", "post"); + builder.AddAttribute(sequence++, "action", $"/api/configuration/file-profiles/{Uri.EscapeDataString(profile!.FileProfileId)}/fields/{section}"); + builder.AddAttribute(sequence++, "class", "config-form"); + + builder.OpenElement(sequence++, "input"); + builder.AddAttribute(sequence++, "type", "hidden"); + builder.AddAttribute(sequence++, "name", "ReturnUrl"); + builder.AddAttribute(sequence++, "value", $"/ops/config/file-profiles/{Uri.EscapeDataString(profile!.FileProfileId)}"); + builder.CloseElement(); + + builder.OpenElement(sequence++, "input"); + builder.AddAttribute(sequence++, "type", "hidden"); + builder.AddAttribute(sequence++, "name", "OriginalSortOrder"); + builder.AddAttribute(sequence++, "value", string.Empty); + builder.CloseElement(); + + builder.OpenElement(sequence++, "div"); + builder.AddAttribute(sequence++, "class", "form-grid"); + AddFieldInput(builder, ref sequence, "Name", string.Empty); + AddFieldInput(builder, ref sequence, "Source", string.Empty); + AddFieldInput(builder, ref sequence, "Format", string.Empty); + AddFieldInput(builder, ref sequence, "Value", string.Empty); + builder.CloseElement(); + + builder.OpenElement(sequence++, "div"); + builder.AddAttribute(sequence++, "class", "form-actions"); + builder.OpenElement(sequence++, "button"); + builder.AddAttribute(sequence++, "type", "submit"); + builder.AddAttribute(sequence++, "class", "primary-button"); + builder.AddContent(sequence++, "Add field"); + builder.CloseElement(); + builder.CloseElement(); + + builder.CloseElement(); + builder.CloseElement(); + }; + + private static void AddFieldInput(RenderTreeBuilder builder, ref int sequence, string label, string? value) + { + builder.OpenElement(sequence++, "label"); + builder.OpenElement(sequence++, "span"); + builder.AddContent(sequence++, label); + builder.CloseElement(); + builder.OpenElement(sequence++, "input"); + builder.AddAttribute(sequence++, "name", label); + builder.AddAttribute(sequence++, "class", "text-input"); + builder.AddAttribute(sequence++, "value", value ?? string.Empty); + builder.CloseElement(); + builder.CloseElement(); + } + + private string? ResolveStatusMessage() + { + var query = Navigation.ToAbsoluteUri(Navigation.Uri).Query; + + if (query.Contains("saved=1", StringComparison.OrdinalIgnoreCase)) + { + return "File profile saved."; + } + + if (query.Contains("error=", StringComparison.OrdinalIgnoreCase)) + { + var errorIndex = query.IndexOf("error=", StringComparison.OrdinalIgnoreCase); + var errorValue = errorIndex >= 0 ? query[(errorIndex + 6)..] : string.Empty; + var ampersandIndex = errorValue.IndexOf('&'); + if (ampersandIndex >= 0) + { + errorValue = errorValue[..ampersandIndex]; + } + + return string.IsNullOrWhiteSpace(errorValue) ? "Save failed." : Uri.UnescapeDataString(errorValue); + } + + return null; + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsFileProfiles.razor b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsFileProfiles.razor new file mode 100644 index 0000000..3c07dec --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsFileProfiles.razor @@ -0,0 +1,299 @@ +@page "/ops/config/file-profiles" +@inject IMerchantProcessingConfigurationStore ConfigurationStore +@inject NavigationManager Navigation + +Merchant File Processor - File Profiles + +@if (!loaded) +{ +
+

Loading file profiles...

+
+} +else +{ +
+ @if (!string.IsNullOrWhiteSpace(statusMessage)) + { +
@statusMessage
+ } + +
+
+
File profiles
+

Layouts and field tables

+

Select a profile from the table to edit its metadata. Use the detail link to open the field tables for the selected profile.

+
+
+
+
Profiles
+
@snapshot.Options.FileProfiles.Count
+
+
+
Delimited
+
@snapshot.Options.FileProfiles.Count(profile => profile.Format.Equals(FileProfileFormats.Delimited, StringComparison.OrdinalIgnoreCase))
+
+
+
JSON
+
@snapshot.Options.FileProfiles.Count(profile => profile.Format.Equals(FileProfileFormats.Json, StringComparison.OrdinalIgnoreCase))
+
+
+
+ +
+
+
+

Existing profiles

+

Select a row to edit it.

+
+ @if (snapshot.Options.FileProfiles.Count == 0) + { +
No file profiles are configured yet.
+ } + else + { +
+ + + + + + + + + + + + @foreach (var profile in snapshot.Options.FileProfiles) + { + var isSelected = string.Equals(profile.FileProfileId, selectedProfileId, StringComparison.OrdinalIgnoreCase); + + + + + + + + } + +
File profileProcessor profileFormatLayoutFields
+ @profile.FileProfileId + @profile.FileProcessorFileProfileId@profile.Format@DescribeLayout(profile)@profile.Fields.Count
+
+ } +
+ +
+
+
+

@(selectedProfile?.FileProfileId ?? "Profile details")

+

@(selectedProfile is null ? "Select a profile on the left." : selectedProfile.FileProcessorFileProfileId)

+
+ + @if (selectedProfile is null) + { +
Pick a profile from the table to edit it here.
+ } + else + { +
+ + +
+ + + + + + + + + + +
+
+ + Open field tables +
+
+ } +
+ +
+
+

Add profile

+

Create a new profile, then open it to manage fields.

+
+
+ + +
+ + + + + + +
+
+ + Back to configuration hub +
+
+
+
+
+
+} + +@code { + private MerchantProcessingConfigurationSnapshot snapshot = new(new MerchantProcessingOptions(), string.Empty, DateTimeOffset.MinValue); + private bool loaded; + private string? statusMessage; + private string selectedProfileId = string.Empty; + + private FileProfileOptions? selectedProfile => snapshot.Options.FileProfiles.FirstOrDefault(profile => + profile.FileProfileId.Equals(selectedProfileId, StringComparison.OrdinalIgnoreCase)); + + protected override async Task OnInitializedAsync() + { + snapshot = await ConfigurationStore.GetCurrentSnapshotAsync(CancellationToken.None); + selectedProfileId = GetQueryValue("fileProfileId") ?? snapshot.Options.FileProfiles.FirstOrDefault()?.FileProfileId ?? string.Empty; + statusMessage = ResolveStatusMessage(); + loaded = true; + } + + private string BuildSelectionHref(string fileProfileId) => + $"/ops/config/file-profiles?fileProfileId={Uri.EscapeDataString(fileProfileId)}"; + + private string BuildReturnUrl(string fileProfileId) => + BuildSelectionHref(fileProfileId); + + private string DescribeLayout(FileProfileOptions profile) + { + var parts = new List { $"{profile.Format} / {profile.FileExtension}" }; + + if (profile.Format.Equals(FileProfileFormats.Delimited, StringComparison.OrdinalIgnoreCase)) + { + parts.Add($"delimiter '{profile.Delimited.Delimiter}'"); + parts.Add(profile.Delimited.IncludeHeader ? "header row" : "no header row"); + } + + if (profile.Format.Equals(FileProfileFormats.Json, StringComparison.OrdinalIgnoreCase)) + { + parts.Add(profile.Json.WriteIndented ? "indented JSON" : "compact JSON"); + if (!string.IsNullOrWhiteSpace(profile.Json.RootPropertyName)) + { + parts.Add($"root '{profile.Json.RootPropertyName}'"); + } + } + + return string.Join(", ", parts); + } + + private string? GetQueryValue(string key) + { + var query = Navigation.ToAbsoluteUri(Navigation.Uri).Query; + if (string.IsNullOrWhiteSpace(query)) + { + return null; + } + + query = query.TrimStart('?'); + foreach (var part in query.Split('&', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var pieces = part.Split('=', 2); + if (pieces.Length == 2 && pieces[0].Equals(key, StringComparison.OrdinalIgnoreCase)) + { + return Uri.UnescapeDataString(pieces[1]); + } + } + + return null; + } + + private string? ResolveStatusMessage() + { + var query = Navigation.ToAbsoluteUri(Navigation.Uri).Query; + + if (query.Contains("saved=1", StringComparison.OrdinalIgnoreCase)) + { + return "File profile saved."; + } + + if (query.Contains("error=", StringComparison.OrdinalIgnoreCase)) + { + var errorIndex = query.IndexOf("error=", StringComparison.OrdinalIgnoreCase); + var errorValue = errorIndex >= 0 ? query[(errorIndex + 6)..] : string.Empty; + var ampersandIndex = errorValue.IndexOf('&'); + if (ampersandIndex >= 0) + { + errorValue = errorValue[..ampersandIndex]; + } + + return string.IsNullOrWhiteSpace(errorValue) ? "Save failed." : Uri.UnescapeDataString(errorValue); + } + + return null; + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsMerchants.razor b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsMerchants.razor new file mode 100644 index 0000000..a9165a9 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsMerchants.razor @@ -0,0 +1,261 @@ +@page "/ops/config/merchants" +@inject IMerchantProcessingConfigurationStore ConfigurationStore +@inject NavigationManager Navigation + +Merchant File Processor - Merchants + +@if (!loaded) +{ +
+

Loading merchants...

+
+} +else +{ +
+ @if (!string.IsNullOrWhiteSpace(statusMessage)) + { +
@statusMessage
+ } + +
+
+
Merchants
+

Schedule and estate management

+

Select a merchant from the table to edit it in the detail pane. The table stays focused on scanning; the editor handles changes.

+
+
+
+
Merchants
+
@snapshot.Options.Merchants.Count
+
+
+
Enabled
+
@snapshot.Options.Merchants.Count(merchant => merchant.Enabled)
+
+
+
Updated
+
@snapshot.UpdatedUtc.ToString("u")
+
+
+
+ +
+
+
+

Existing merchants

+

Select a row to edit it.

+
+ @if (snapshot.Options.Merchants.Count == 0) + { +
No merchants are configured yet.
+ } + else + { +
+ + + + + + + + + + + @foreach (var merchant in snapshot.Options.Merchants) + { + var isSelected = string.Equals(merchant.MerchantId, selectedMerchantId, StringComparison.OrdinalIgnoreCase); + + + + + + + } + +
MerchantEnabledEstateRun times
+ @merchant.Name
+ @merchant.MerchantId +
@((merchant.Enabled) ? "Enabled" : "Disabled")@merchant.EstateId@FormatRunTimes(merchant)
+
+ } +
+ +
+
+
+

@(selectedMerchant?.Name ?? "Merchant details")

+

@(selectedMerchant is null ? "Select a merchant on the left." : selectedMerchant.MerchantId)

+
+ + @if (selectedMerchant is null) + { +
Pick a merchant from the table to edit it here.
+ } + else + { +
+ + +
+ + + + + + +
+
+ +
+
+ } +
+ +
+
+

Add merchant

+

Create a new merchant entry.

+
+
+ + +
+ + + + + + +
+
+ + Back to configuration hub +
+
+
+
+
+
+} + +@code { + private MerchantProcessingConfigurationSnapshot snapshot = new(new MerchantProcessingOptions(), string.Empty, DateTimeOffset.MinValue); + private bool loaded; + private string? statusMessage; + private string selectedMerchantId = string.Empty; + + private MerchantOptions? selectedMerchant => snapshot.Options.Merchants.FirstOrDefault(merchant => + merchant.MerchantId.Equals(selectedMerchantId, StringComparison.OrdinalIgnoreCase)); + + protected override async Task OnInitializedAsync() + { + snapshot = await ConfigurationStore.GetCurrentSnapshotAsync(CancellationToken.None); + selectedMerchantId = GetQueryValue("merchantId") ?? snapshot.Options.Merchants.FirstOrDefault()?.MerchantId ?? string.Empty; + statusMessage = ResolveStatusMessage(); + loaded = true; + } + + private string BuildSelectionHref(string merchantId) => + $"/ops/config/merchants?merchantId={Uri.EscapeDataString(merchantId)}"; + + private string BuildReturnUrl(string merchantId) => + BuildSelectionHref(merchantId); + + private string FormatRunTimes(MerchantOptions merchant) + { + var runTimes = merchant.RunTimesUtc.Count > 0 ? merchant.RunTimesUtc : [merchant.RunAtUtc]; + return string.Join(", ", runTimes); + } + + private string? GetQueryValue(string key) + { + var query = Navigation.ToAbsoluteUri(Navigation.Uri).Query; + if (string.IsNullOrWhiteSpace(query)) + { + return null; + } + + query = query.TrimStart('?'); + foreach (var part in query.Split('&', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var pieces = part.Split('=', 2); + if (pieces.Length == 2 && pieces[0].Equals(key, StringComparison.OrdinalIgnoreCase)) + { + return Uri.UnescapeDataString(pieces[1]); + } + } + + return null; + } + + private string? ResolveStatusMessage() + { + var query = Navigation.ToAbsoluteUri(Navigation.Uri).Query; + + if (query.Contains("saved=1", StringComparison.OrdinalIgnoreCase)) + { + return "Merchant saved."; + } + + if (query.Contains("error=", StringComparison.OrdinalIgnoreCase)) + { + var errorIndex = query.IndexOf("error=", StringComparison.OrdinalIgnoreCase); + var errorValue = errorIndex >= 0 ? query[(errorIndex + 6)..] : string.Empty; + var ampersandIndex = errorValue.IndexOf('&'); + if (ampersandIndex >= 0) + { + errorValue = errorValue[..ampersandIndex]; + } + + return string.IsNullOrWhiteSpace(errorValue) ? "Save failed." : Uri.UnescapeDataString(errorValue); + } + + return null; + } + + private static string BadgeClass(string tone) => $"badge {tone}"; +} diff --git a/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsOverview.razor b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsOverview.razor new file mode 100644 index 0000000..d2694f1 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsOverview.razor @@ -0,0 +1,215 @@ +@page "/ops" +@inject IOperationsDashboardService Dashboard + +Merchant File Processor - Operations + +@if (model is null) +{ +
+

Loading operations data...

+
+} +else +{ +
+
+
+
Operations console
+

Merchant File Processor

+

Live configuration, scheduling, and processing visibility for the worker, driven directly from the loaded appsettings and database state.

+
+
+
+
Environment
+
@model.EnvironmentName
+
+
+
Generated
+
@model.GeneratedUtc.ToString("u")
+
+
+
Database
+
@model.ConnectionStringSummary
+
+
+
+ +
+ @foreach (var metric in model.Metrics) + { +
+
@metric.Label
+
@metric.Value
+
@metric.Detail
+
+ } +
+ +
+
+

Merchant schedule

+

Schedules are evaluated in UTC and aligned to the configured merchant run slots.

+
+
+ + + + + + + + + + + + + + + + + @foreach (var merchant in model.Merchants) + { + + + + + + + + + + + + + } + +
MerchantEnabledEstateRun times (UTC)Last merchant runNext runSuccessFailedPending checksStatus
+ @merchant.MerchantName
+ @merchant.MerchantId +
@((merchant.Enabled) ? "Enabled" : "Disabled")@merchant.EstateId@merchant.RunTimesUtc@((merchant.LastMerchantRunUtc?.ToString("u")) ?? "Never")@((merchant.NextScheduledUtc?.ToString("u")) ?? "Disabled")@merchant.SuccessfulFiles@merchant.FailedFiles@merchant.PendingStatusChecksView status
+
+
+ +
+
+
+

File profiles

+

These profiles drive file construction for each contract mapping.

+
+
+ + + + + + + + + + + @foreach (var profile in model.FileProfiles) + { + + + + + + + } + +
ProfileFormatLayoutFields
+ @profile.FileProfileId
+ @profile.FileProcessorFileProfileId +
@profile.Format@profile.LayoutSummary
.@profile.FileExtension@if (!string.IsNullOrWhiteSpace(profile.ContentType)) { / @profile.ContentType }
@profile.FieldCount total
@profile.HeaderFieldCount header, @profile.TrailerFieldCount trailer
+
+
+ +
+
+

Contract mappings

+

Each contract resolves to the file profile used at generation time.

+
+
+ + + + + + + + + @foreach (var contract in model.Contracts) + { + + + + + } + +
ContractFile profile
@contract.ContractId@contract.FileProfileId
+
+
+
+ +
+
+

Recent merchant runs

+

Latest completed runs pulled from the local audit database.

+
+
+ + + + + + + + + + + + @foreach (var run in model.RecentRuns) + { + + + + + + + + } + +
MerchantScheduledCompletedStatusError
+ @run.MerchantName
+ @run.MerchantId +
@run.ScheduledRunUtc.ToString("u")@run.CompletedUtc.ToString("u")@run.Status@run.ErrorMessage
+
+
+
+} + +@code { + private OperationsDashboardModel? model; + + protected override async Task OnInitializedAsync() + { + this.model = await Dashboard.GetDashboardModelAsync(CancellationToken.None); + } + + private static string BadgeClass(string tone) => $"badge {tone}"; + + private static string RunTone(string status) + { + if (status.Equals(MerchantRunStatuses.Succeeded, StringComparison.OrdinalIgnoreCase)) + { + return "good"; + } + + if (status.Equals(MerchantRunStatuses.Failed, StringComparison.OrdinalIgnoreCase)) + { + return "bad"; + } + + return "neutral"; + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsRuns.razor b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsRuns.razor new file mode 100644 index 0000000..aab79ad --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsRuns.razor @@ -0,0 +1,111 @@ +@page "/ops/runs" +@inject IOperationsDashboardService Dashboard + +Merchant File Processor - Run history + +@if (model is null) +{ +
+

Loading run history...

+
+} +else +{ +
+
+
+
Run history
+

Merchant execution audit trail

+

Each row captures the scheduled slot, completion time, and final outcome for a merchant processing cycle.

+
+
+
+
Generated
+
@model.GeneratedUtc.ToString("u")
+
+
+
Recent runs
+
@model.RecentRuns.Count
+
+
+
Successful
+
@SuccessfulRuns
+
+
+
Failed
+
@FailedRuns
+
+
+
+ +
+
+

Run records

+

Most recent entries appear first, with the latest processing outcome at the top.

+
+
+ + + + + + + + + + + + @foreach (var run in model.RecentRuns) + { + + + + + + + + } + +
MerchantScheduledCompletedStatusError
+ @run.MerchantName
+ @run.MerchantId +
@run.ScheduledRunUtc.ToString("u")@run.CompletedUtc.ToString("u")@run.Status@RenderError(run.ErrorMessage)
+
+
+
+} + +@code { + private OperationsDashboardModel? model; + + protected override async Task OnInitializedAsync() + { + this.model = await Dashboard.GetDashboardModelAsync(CancellationToken.None); + } + + private static string BadgeClass(string tone) => $"badge {tone}"; + + private int SuccessfulRuns => + model?.RecentRuns.Count(run => run.Status.Equals(MerchantRunStatuses.Succeeded, StringComparison.OrdinalIgnoreCase)) ?? 0; + + private int FailedRuns => + model?.RecentRuns.Count(run => run.Status.Equals(MerchantRunStatuses.Failed, StringComparison.OrdinalIgnoreCase)) ?? 0; + + private static string RunTone(string status) + { + if (status.Equals(MerchantRunStatuses.Succeeded, StringComparison.OrdinalIgnoreCase)) + { + return "good"; + } + + if (status.Equals(MerchantRunStatuses.Failed, StringComparison.OrdinalIgnoreCase)) + { + return "bad"; + } + + return "neutral"; + } + + private static string RenderError(string? errorMessage) => + string.IsNullOrWhiteSpace(errorMessage) ? "No error recorded" : errorMessage; +} diff --git a/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsRuntime.razor b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsRuntime.razor new file mode 100644 index 0000000..f9ec76b --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Components/Pages/OpsRuntime.razor @@ -0,0 +1,129 @@ +@page "/ops/config/runtime" +@inject IMerchantProcessingConfigurationStore ConfigurationStore +@inject NavigationManager Navigation + +Merchant File Processor - Runtime Settings + +@if (!loaded) +{ +
+

Loading runtime settings...

+
+} +else +{ +
+ @if (!string.IsNullOrWhiteSpace(statusMessage)) + { +
@statusMessage
+ } + +
+
+
Runtime settings
+

Authentication and worker cadence

+

These settings feed the service at startup and are stored as individual SQLite rows rather than a JSON blob.

+
+
+
+
Updated
+
@snapshot.UpdatedUtc.ToString("u")
+
+
+
Authentication
+
@snapshot.Options.Authentication.ClientId
+
+
+
Polling
+
@snapshot.Options.FileStatusPolling.PollIntervalSeconds seconds
+
+
+
+ +
+
+

Edit runtime settings

+

Saving replaces the runtime rows in SQLite and refreshes the live in-memory snapshot.

+
+
+ +
+ + + + + + + + +
+
+ + Back to configuration hub +
+
+
+
+} + +@code { + private MerchantProcessingConfigurationSnapshot snapshot = new(new MerchantProcessingOptions(), string.Empty, DateTimeOffset.MinValue); + private bool loaded; + private string? statusMessage; + + protected override async Task OnInitializedAsync() + { + snapshot = await ConfigurationStore.GetCurrentSnapshotAsync(CancellationToken.None); + statusMessage = ResolveStatusMessage(); + loaded = true; + } + + private string? ResolveStatusMessage() + { + var query = Navigation.ToAbsoluteUri(Navigation.Uri).Query; + + if (query.Contains("saved=1", StringComparison.OrdinalIgnoreCase)) + { + return "Runtime settings saved."; + } + + if (query.Contains("error=", StringComparison.OrdinalIgnoreCase)) + { + var errorIndex = query.IndexOf("error=", StringComparison.OrdinalIgnoreCase); + var errorValue = errorIndex >= 0 ? query[(errorIndex + 6)..] : string.Empty; + var ampersandIndex = errorValue.IndexOf('&'); + if (ampersandIndex >= 0) + { + errorValue = errorValue[..ampersandIndex]; + } + + return string.IsNullOrWhiteSpace(errorValue) ? "Save failed." : Uri.UnescapeDataString(errorValue); + } + + return null; + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/Components/Pages/StatusFile.razor b/TransactionProcessing.MerchantFileProcessor/Components/Pages/StatusFile.razor new file mode 100644 index 0000000..94406d8 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Components/Pages/StatusFile.razor @@ -0,0 +1,170 @@ +@page "/status/{MerchantId}/files/{FileId:long}" +@inject IFileStatusReportService Reports + +Merchant File Processor - File Status + +@if (!loaded) +{ +
+

Loading file details...

+
+} +else if (report is null) +{ +
+
+
+
File details
+

File not found

+

The selected file could not be found for the current merchant.

+

← Back to all merchants

+
+
+
+} +else +{ +
+
+
+
File details
+

@report.File.FileName

+

@report.Merchant.MerchantName / @report.Merchant.MerchantId

+

This page shows the full audit trail for one generated merchant file and its individual line statuses.

+
+
+
+
Processed
+
@report.File.ProcessedUtc.ToString("u")
+
+
+
Status
+
@report.File.Status
+
+
+
Processing
+
@((report.File.ProcessingCompleted) ? "Complete" : "Pending")
+
+
+
Lines
+
@report.FileLines.Count
+
+
+
+ +
+
+
+

File summary

+

Generated file metadata and status tracking.

+
+
+
Merchant
@report.Merchant.MerchantName
+
Contract
@report.File.ContractName
+
File profile
@report.File.FileProfileId
+
Format
@report.File.Format
+
File name
@(string.IsNullOrWhiteSpace(report.File.FileName) ? "(not generated)" : report.File.FileName)
+
Record count
@report.File.RecordCount
+
Total amount
@report.File.TotalAmount.ToString("0.00")
+
Last status check
@((report.File.LastStatusCheckUtc?.ToString("u")) ?? "Never")
+
+
+ + +
+ +
+
+

File lines

+

Line-level processing statuses captured by the polling worker.

+
+ @if (report.FileLines.Count == 0) + { +
No file content has been recorded for this file yet.
+ } + else + { +
+ + + + + + + + + + + + @foreach (var line in report.FileLines) + { + + + + + + + + } + +
LineContentStatusTransactionRejection
@line.LineNumber@line.Content@line.ProcessingStatus@line.TransactionId@line.RejectionReason
+
+ } +
+ +
+
+

Error details

+
+
@(string.IsNullOrWhiteSpace(report.File.ErrorMessage) ? "No error recorded." : report.File.ErrorMessage)
+
+
+} + +@code { + [Parameter] + public string MerchantId { get; set; } = string.Empty; + + [Parameter] + public long FileId { get; set; } + + private FileDetailReport? report; + private bool loaded; + + protected override async Task OnParametersSetAsync() + { + this.loaded = false; + this.report = await Reports.GetFileReportAsync(MerchantId, FileId, CancellationToken.None); + this.loaded = true; + } + + private static string BadgeClass(string tone) => $"badge {tone}"; + + private static string LineTone(string status) + { + if (status.Equals(FileLineStatuses.Successful, StringComparison.OrdinalIgnoreCase)) + { + return "good"; + } + + if (status.Equals(FileLineStatuses.Failed, StringComparison.OrdinalIgnoreCase) || + status.Equals(FileLineStatuses.Rejected, StringComparison.OrdinalIgnoreCase)) + { + return "bad"; + } + + if (status.Equals(FileLineStatuses.Ignored, StringComparison.OrdinalIgnoreCase)) + { + return "neutral"; + } + + return "warn"; + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/Components/Pages/StatusMerchant.razor b/TransactionProcessing.MerchantFileProcessor/Components/Pages/StatusMerchant.razor new file mode 100644 index 0000000..c01c808 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Components/Pages/StatusMerchant.razor @@ -0,0 +1,157 @@ +@page "/status/{MerchantId}" +@inject IFileStatusReportService Reports + +Merchant File Processor - Merchant Status + +@if (!loaded) +{ +
+

Loading merchant status...

+
+} +else if (report is null) +{ +
+
+
+
Merchant status
+

Merchant not found

+

The selected merchant ID does not exist in the current configuration.

+

← Back to all merchants

+
+
+
+} +else +{ +
+
+
+
Merchant status
+

@report.Merchant.MerchantName

+

@report.Merchant.MerchantId

+

This merchant-specific page shows the run history and file activity for the selected merchant.

+
+
+
+
Enabled
+
@((report.Merchant.Enabled) ? "Yes" : "No")
+
+
+
Successful files
+
@report.Merchant.SuccessfulFilesSent
+
+
+
Failed files
+
@report.Merchant.FailedFiles
+
+
+
Recent files
+
@report.RecentFiles.Count
+
+
+
+ +
+
+
+

Merchant summary

+

Current aggregated counts from the audit trail.

+
+
+
Last processed
@((report.Merchant.LastProcessedUtc?.ToString("u")) ?? "Never")
+
Next scheduled send
@((report.Merchant.NextScheduledUtc?.ToString("u")) ?? "Disabled")
+
Success count
@report.Merchant.SuccessfulFilesSent
+
Failure count
@report.Merchant.FailedFiles
+
+
+ + +
+ +
+
+

Recent file activity

+

Latest file records for this merchant.

+
+
+ + + + + + + + + + + + @if (report.RecentFiles.Count == 0) + { + + } + else + { + @foreach (var file in report.RecentFiles) + { + + + + + + + + } + } + +
ProcessedContractStatusProfileDetails
No file activity has been recorded for this merchant yet.
@file.ProcessedUtc.ToString("u") + @file.ContractName
+ @file.ContractId +
@RenderStatus(file)@file.FileProfileIdView file
+
+
+
+} + +@code { + [Parameter] + public string MerchantId { get; set; } = string.Empty; + + private MerchantDetailReport? report; + private bool loaded; + + protected override async Task OnParametersSetAsync() + { + this.loaded = false; + this.report = await Reports.GetMerchantReportAsync(MerchantId, CancellationToken.None); + this.loaded = true; + } + + private static string BadgeClass(string tone) => $"badge {tone}"; + + private static string FileTone(string status) + { + if (status.Equals(FileSendStatuses.Succeeded, StringComparison.OrdinalIgnoreCase)) + { + return "good"; + } + + if (status.Equals(FileSendStatuses.Failed, StringComparison.OrdinalIgnoreCase)) + { + return "bad"; + } + + return "neutral"; + } + + private static string RenderStatus(FileStatusRow file) => + file.Status == FileSendStatuses.Succeeded + ? $"{file.Status} / {(file.ProcessingCompleted ? "Complete" : "Pending")}" + : file.Status; +} diff --git a/TransactionProcessing.MerchantFileProcessor/Components/Pages/StatusOverview.razor b/TransactionProcessing.MerchantFileProcessor/Components/Pages/StatusOverview.razor new file mode 100644 index 0000000..86c283b --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Components/Pages/StatusOverview.razor @@ -0,0 +1,165 @@ +@page "/status" +@inject IFileStatusReportService Reports + +Merchant File Processor - Status + +@if (report is null) +{ +
+

Loading status board...

+
+} +else +{ +
+
+
+
Status board
+

Merchant file activity

+

Live operational view of merchant file processing, including merchant rollups and the latest file activity.

+
+
+
+
Generated
+
@report.GeneratedUtc.ToString("u")
+
+
+
Merchants
+
@report.MerchantSummaries.Count
+
+
+
Enabled
+
@report.MerchantSummaries.Count(merchant => merchant.Enabled)
+
+
+
Recent files
+
@report.RecentFiles.Count
+
+
+
+ +
+
+

Merchants

+

Success and failure counts are aggregated from the local file audit store.

+
+
+ + + + + + + + + + + + + + @if (report.MerchantSummaries.Count == 0) + { + + } + else + { + @foreach (var merchant in report.MerchantSummaries) + { + + + + + + + + + + } + } + +
MerchantEnabledSuccessful filesFailed filesLast processedNext scheduled sendDetails
No merchants are configured.
+ @merchant.MerchantName
+ @merchant.MerchantId +
@((merchant.Enabled) ? "Enabled" : "Disabled")@merchant.SuccessfulFilesSent@merchant.FailedFiles@((merchant.LastProcessedUtc?.ToString("u")) ?? "Never")@((merchant.NextScheduledUtc?.ToString("u")) ?? "Disabled")View details
+
+
+ +
+
+

Recent file activity

+

Latest files recorded by the processor, sorted by processing time.

+
+
+ + + + + + + + + + + + + @if (report.RecentFiles.Count == 0) + { + + } + else + { + @foreach (var file in report.RecentFiles) + { + + + + + + + + + } + } + +
ProcessedMerchantContractStatusProfileDetails
No recent file activity has been recorded yet.
@file.ProcessedUtc.ToString("u") + @file.MerchantName
+ @file.MerchantId +
+ @file.ContractName
+ @file.ContractId +
@RenderStatus(file)@file.FileProfileIdView file
+
+
+
+} + +@code { + private FileStatusReport? report; + + protected override async Task OnInitializedAsync() + { + this.report = await Reports.GetReportAsync(CancellationToken.None); + } + + private static string BadgeClass(string tone) => $"badge {tone}"; + + private static string FileTone(string status) + { + if (status.Equals(FileSendStatuses.Succeeded, StringComparison.OrdinalIgnoreCase)) + { + return "good"; + } + + if (status.Equals(FileSendStatuses.Failed, StringComparison.OrdinalIgnoreCase)) + { + return "bad"; + } + + return "neutral"; + } + + private static string RenderStatus(FileStatusRow file) => + file.Status == FileSendStatuses.Succeeded + ? $"{file.Status} / {(file.ProcessingCompleted ? "Complete" : "Pending")}" + : file.Status; +} diff --git a/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingConfigurationState.cs b/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingConfigurationState.cs new file mode 100644 index 0000000..7e9f133 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingConfigurationState.cs @@ -0,0 +1,38 @@ +namespace TransactionProcessing.MerchantFileProcessor.Configuration; + +public sealed record MerchantProcessingConfigurationSnapshot( + MerchantProcessingOptions Options, + string Json, + DateTimeOffset UpdatedUtc); + +public interface IMerchantProcessingConfigurationState +{ + MerchantProcessingOptions Current { get; } + + string CurrentJson { get; } + + DateTimeOffset UpdatedUtc { get; } + + bool IsLoaded { get; } + + void Set(MerchantProcessingConfigurationSnapshot snapshot); +} + +public sealed class MerchantProcessingConfigurationState : IMerchantProcessingConfigurationState +{ + private MerchantProcessingConfigurationSnapshot snapshot = new(new MerchantProcessingOptions(), string.Empty, DateTimeOffset.MinValue); + + public MerchantProcessingOptions Current => this.snapshot.Options; + + public string CurrentJson => this.snapshot.Json; + + public DateTimeOffset UpdatedUtc => this.snapshot.UpdatedUtc; + + public bool IsLoaded { get; private set; } + + public void Set(MerchantProcessingConfigurationSnapshot snapshot) + { + this.snapshot = snapshot; + this.IsLoaded = true; + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingConfigurationStore.cs b/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingConfigurationStore.cs new file mode 100644 index 0000000..86ed253 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingConfigurationStore.cs @@ -0,0 +1,528 @@ +using System.Globalization; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using TransactionProcessing.MerchantFileProcessor.Persistence; + +namespace TransactionProcessing.MerchantFileProcessor.Configuration; + +public interface IMerchantProcessingConfigurationStore +{ + Task GetCurrentSnapshotAsync(CancellationToken cancellationToken); + + Task SaveJsonAsync(string configurationJson, CancellationToken cancellationToken); + + Task SaveAsync(MerchantProcessingOptions options, CancellationToken cancellationToken); +} + +public sealed class MerchantProcessingConfigurationStore( + IDbContextFactory dbContextFactory, + IConfiguration configuration, + IMerchantProcessingConfigurationState state, + JsonSerializerOptions jsonSerializerOptions) : IMerchantProcessingConfigurationStore +{ + private readonly SemaphoreSlim loadLock = new(1, 1); + + public async Task GetCurrentSnapshotAsync(CancellationToken cancellationToken) + { + if (state.IsLoaded) + { + return new MerchantProcessingConfigurationSnapshot(state.Current, state.CurrentJson, state.UpdatedUtc); + } + + await this.EnsureLoadedAsync(cancellationToken); + return new MerchantProcessingConfigurationSnapshot(state.Current, state.CurrentJson, state.UpdatedUtc); + } + + public async Task SaveJsonAsync(string configurationJson, CancellationToken cancellationToken) + { + var options = JsonSerializer.Deserialize(configurationJson, jsonSerializerOptions) + ?? throw new InvalidOperationException("The supplied configuration JSON could not be deserialised."); + + return await this.SaveAsync(options, cancellationToken); + } + + public async Task SaveAsync(MerchantProcessingOptions options, CancellationToken cancellationToken) + { + if (!MerchantProcessingOptionsValidator.Validate(options)) + { + throw new InvalidOperationException("MerchantProcessing configuration is invalid."); + } + + var updatedUtc = DateTimeOffset.UtcNow; + var json = JsonSerializer.Serialize(options, jsonSerializerOptions); + var snapshot = new MerchantProcessingConfigurationSnapshot(options, json, updatedUtc); + + await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); + + await ClearConfigurationTablesAsync(dbContext, cancellationToken); + await PersistSingletonsAsync(dbContext, options, updatedUtc, cancellationToken); + await PersistFileProfilesAsync(dbContext, options.FileProfiles, updatedUtc, cancellationToken); + await PersistContractsAsync(dbContext, options.ContractDefinitions, updatedUtc, cancellationToken); + await PersistMerchantsAsync(dbContext, options.Merchants, updatedUtc, cancellationToken); + + await dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + + state.Set(snapshot); + return snapshot; + } + + public async Task EnsureLoadedAsync(CancellationToken cancellationToken) + { + if (state.IsLoaded) + { + return; + } + + await loadLock.WaitAsync(cancellationToken); + try + { + if (state.IsLoaded) + { + return; + } + + await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + var snapshot = await TryLoadFromRelationalTablesAsync(dbContext, cancellationToken) + ?? await TryLoadFromLegacyConfigurationAsync(dbContext, cancellationToken) + ?? BuildBootstrapSnapshot(); + + if (!MerchantProcessingOptionsValidator.Validate(snapshot.Options)) + { + throw new InvalidOperationException("No valid MerchantProcessing configuration was found in SQLite or the bootstrap configuration."); + } + + if (!await HasNormalizedConfigurationAsync(dbContext, cancellationToken)) + { + await this.SaveAsync(snapshot.Options, cancellationToken); + return; + } + + state.Set(snapshot); + } + finally + { + loadLock.Release(); + } + } + + private MerchantProcessingConfigurationSnapshot BuildBootstrapSnapshot() + { + var seedOptions = configuration.GetSection(MerchantProcessingOptions.SectionName).Get() ?? new MerchantProcessingOptions(); + var updatedUtc = DateTimeOffset.UtcNow; + var json = JsonSerializer.Serialize(seedOptions, jsonSerializerOptions); + return new MerchantProcessingConfigurationSnapshot(seedOptions, json, updatedUtc); + } + + private async Task TryLoadFromRelationalTablesAsync(MerchantFileProcessorDbContext dbContext, CancellationToken cancellationToken) + { + var authentication = await dbContext.MerchantProcessingAuthenticationRecords.AsNoTracking().FirstOrDefaultAsync(record => record.Id == 1, cancellationToken); + 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 fileProfiles = await dbContext.MerchantProcessingFileProfileRecords.AsNoTracking() + .OrderBy(record => record.SortOrder) + .ToListAsync(cancellationToken); + + var contractDefinitions = await dbContext.MerchantProcessingContractDefinitionRecords.AsNoTracking() + .OrderBy(record => record.SortOrder) + .ToListAsync(cancellationToken); + + var merchants = await dbContext.MerchantProcessingMerchantRecords.AsNoTracking() + .OrderBy(record => record.SortOrder) + .ToListAsync(cancellationToken); + + if (authentication is null && fileProcessing is null && transactionGeneration is null && fileStatusPolling 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) + { + return null; + } + + var profileIds = fileProfiles.ToDictionary(record => record.Id); + var fileProfileFields = await LoadFieldRecordsAsync(dbContext.MerchantProcessingFileProfileFieldRecords, cancellationToken); + var headerFieldRecords = await LoadFieldRecordsAsync(dbContext.MerchantProcessingFileProfileHeaderFieldRecords, cancellationToken); + var trailerFieldRecords = await LoadFieldRecordsAsync(dbContext.MerchantProcessingFileProfileTrailerFieldRecords, cancellationToken); + var merchantRunTimes = await LoadRunTimesAsync(dbContext, cancellationToken); + + var options = new MerchantProcessingOptions + { + Authentication = new AuthenticationOptions + { + ClientId = authentication.ClientId, + ClientSecret = authentication.ClientSecret, + Scope = authentication.Scope, + Audience = authentication.Audience + }, + FileProcessing = new FileProcessingOptions + { + UserId = fileProcessing.UserId + }, + TransactionGeneration = new TransactionGenerationOptions + { + MinimumTransactionsPerContract = transactionGeneration.MinimumTransactionsPerContract, + MaximumTransactionsPerContract = transactionGeneration.MaximumTransactionsPerContract + }, + FileStatusPolling = new FileStatusPollingOptions + { + PollIntervalSeconds = fileStatusPolling.PollIntervalSeconds + } + }; + + options.FileProfiles.AddRange(fileProfiles.Select(record => + { + fileProfileFields.TryGetValue(record.Id, out var bodyFields); + headerFieldRecords.TryGetValue(record.Id, out var headerFields); + trailerFieldRecords.TryGetValue(record.Id, out var trailerFields); + + return new FileProfileOptions + { + FileProfileId = record.FileProfileId, + FileProcessorFileProfileId = record.FileProcessorFileProfileId, + Format = record.Format, + FileExtension = record.FileExtension, + FileNamePattern = record.FileNamePattern, + ContentType = record.ContentType, + Delimited = new DelimitedFileProfileOptions + { + Delimiter = record.Delimiter ?? ",", + IncludeHeader = record.IncludeHeader, + HeaderFields = [.. (headerFields ?? [])], + TrailerFields = [.. (trailerFields ?? [])] + }, + Json = new JsonFileProfileOptions + { + WriteIndented = record.WriteIndented, + RootPropertyName = record.RootPropertyName + }, + Fields = [.. (bodyFields ?? [])] + }; + })); + + options.ContractDefinitions.AddRange(contractDefinitions.Select(record => new ContractDefinitionOptions + { + ContractId = record.ContractId, + FileProfileId = record.FileProfileId + })); + + options.Merchants.AddRange(merchants.Select(record => + { + merchantRunTimes.TryGetValue(record.Id, out var runTimes); + + return new MerchantOptions + { + Name = record.Name, + Enabled = record.Enabled, + EstateId = record.EstateId, + MerchantId = record.MerchantId, + RunAtUtc = record.RunAtUtc, + RunTimesUtc = [.. (runTimes ?? [])] + }; + })); + + if (!MerchantProcessingOptionsValidator.Validate(options)) + { + return null; + } + + var updatedUtc = MaxUtc( + [authentication.UpdatedUtc], + [fileProcessing.UpdatedUtc], + [transactionGeneration.UpdatedUtc], + [fileStatusPolling.UpdatedUtc], + fileProfiles.Select(record => record.UpdatedUtc), + contractDefinitions.Select(record => record.UpdatedUtc), + merchants.Select(record => record.UpdatedUtc)); + + var json = JsonSerializer.Serialize(options, jsonSerializerOptions); + return new MerchantProcessingConfigurationSnapshot(options, json, updatedUtc); + } + + private async Task TryLoadFromLegacyConfigurationAsync( + MerchantFileProcessorDbContext dbContext, + CancellationToken cancellationToken) + { + var record = await dbContext.MerchantProcessingConfigurationRecords.AsNoTracking() + .FirstOrDefaultAsync(entry => entry.Id == 1, cancellationToken); + + if (record is null) + { + return null; + } + + var options = JsonSerializer.Deserialize(record.ConfigurationJson, jsonSerializerOptions) + ?? throw new InvalidOperationException("Stored MerchantProcessing configuration could not be deserialised."); + + var updatedUtc = record.UpdatedUtc; + var json = JsonSerializer.Serialize(options, jsonSerializerOptions); + return new MerchantProcessingConfigurationSnapshot(options, json, updatedUtc); + } + + private async Task HasNormalizedConfigurationAsync(MerchantFileProcessorDbContext dbContext, CancellationToken cancellationToken) + { + return await dbContext.MerchantProcessingAuthenticationRecords.AnyAsync(record => record.Id == 1, cancellationToken) || + 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.MerchantProcessingFileProfileRecords.AnyAsync(cancellationToken) || + await dbContext.MerchantProcessingContractDefinitionRecords.AnyAsync(cancellationToken) || + await dbContext.MerchantProcessingMerchantRecords.AnyAsync(cancellationToken); + } + + private static async Task>> LoadFieldRecordsAsync( + IQueryable query, + CancellationToken cancellationToken) + where TEntity : MerchantProcessingFileProfileFieldRecordBase + { + var records = await query.AsNoTracking() + .OrderBy(record => record.FileProfileRecordId) + .ThenBy(record => record.SortOrder) + .ToListAsync(cancellationToken); + + return records + .GroupBy(record => record.FileProfileRecordId) + .ToDictionary( + group => group.Key, + group => group.Select(record => new FileFieldOptions + { + Name = record.Name, + Source = record.Source ?? string.Empty, + Format = record.Format, + Value = record.Value + }).ToList()); + } + + private static async Task>> LoadRunTimesAsync( + MerchantFileProcessorDbContext dbContext, + CancellationToken cancellationToken) + { + var merchantIdLookup = await dbContext.MerchantProcessingMerchantRecords.AsNoTracking() + .Select(record => new { record.Id, record.MerchantId }) + .ToDictionaryAsync(record => record.Id, record => record.MerchantId, cancellationToken); + + var records = await dbContext.MerchantProcessingMerchantRunTimeRecords.AsNoTracking() + .OrderBy(record => record.MerchantRecordId) + .ThenBy(record => record.SortOrder) + .ToListAsync(cancellationToken); + + return records + .Where(record => merchantIdLookup.ContainsKey(record.MerchantRecordId)) + .GroupBy(record => record.MerchantRecordId) + .ToDictionary( + group => group.Key, + group => group.Select(record => record.RunTimeUtc).ToList()); + } + + private static async Task PersistSingletonsAsync( + MerchantFileProcessorDbContext dbContext, + MerchantProcessingOptions options, + DateTimeOffset updatedUtc, + CancellationToken cancellationToken) + { + dbContext.MerchantProcessingAuthenticationRecords.Add(new MerchantProcessingAuthenticationRecord + { + Id = 1, + ClientId = options.Authentication.ClientId, + ClientSecret = options.Authentication.ClientSecret, + Scope = options.Authentication.Scope, + Audience = options.Authentication.Audience, + UpdatedUtc = updatedUtc + }); + + dbContext.MerchantProcessingFileProcessingRecords.Add(new MerchantProcessingFileProcessingRecord + { + Id = 1, + UserId = options.FileProcessing.UserId, + UpdatedUtc = updatedUtc + }); + + dbContext.MerchantProcessingTransactionGenerationRecords.Add(new MerchantProcessingTransactionGenerationRecord + { + Id = 1, + MinimumTransactionsPerContract = options.TransactionGeneration.MinimumTransactionsPerContract, + MaximumTransactionsPerContract = options.TransactionGeneration.MaximumTransactionsPerContract, + UpdatedUtc = updatedUtc + }); + + dbContext.MerchantProcessingFileStatusPollingRecords.Add(new MerchantProcessingFileStatusPollingRecord + { + Id = 1, + PollIntervalSeconds = options.FileStatusPolling.PollIntervalSeconds, + UpdatedUtc = updatedUtc + }); + + await dbContext.SaveChangesAsync(cancellationToken); + } + + private static async Task PersistFileProfilesAsync( + MerchantFileProcessorDbContext dbContext, + IReadOnlyList fileProfiles, + DateTimeOffset updatedUtc, + CancellationToken cancellationToken) + { + var profileRecords = fileProfiles + .Select((profile, index) => new MerchantProcessingFileProfileRecord + { + SortOrder = index, + FileProfileId = profile.FileProfileId, + FileProcessorFileProfileId = profile.FileProcessorFileProfileId, + Format = profile.Format, + FileExtension = profile.FileExtension, + FileNamePattern = profile.FileNamePattern, + ContentType = profile.ContentType, + Delimiter = profile.Delimited.Delimiter, + IncludeHeader = profile.Delimited.IncludeHeader, + WriteIndented = profile.Json.WriteIndented, + RootPropertyName = profile.Json.RootPropertyName, + UpdatedUtc = updatedUtc + }) + .ToArray(); + + dbContext.MerchantProcessingFileProfileRecords.AddRange(profileRecords); + await dbContext.SaveChangesAsync(cancellationToken); + + var bodyFields = new List(); + var headerFields = new List(); + var trailerFields = new List(); + + foreach (var profileRecord in profileRecords) + { + var profile = fileProfiles[profileRecord.SortOrder]; + + bodyFields.AddRange(profile.Fields.Select((field, index) => new MerchantProcessingFileProfileFieldRecord + { + FileProfileRecordId = profileRecord.Id, + SortOrder = index, + Name = field.Name, + Source = field.Source, + Format = field.Format, + Value = field.Value, + UpdatedUtc = updatedUtc + })); + + headerFields.AddRange(profile.Delimited.HeaderFields.Select((field, index) => new MerchantProcessingFileProfileHeaderFieldRecord + { + FileProfileRecordId = profileRecord.Id, + SortOrder = index, + Name = field.Name, + Source = field.Source, + Format = field.Format, + Value = field.Value, + UpdatedUtc = updatedUtc + })); + + trailerFields.AddRange(profile.Delimited.TrailerFields.Select((field, index) => new MerchantProcessingFileProfileTrailerFieldRecord + { + FileProfileRecordId = profileRecord.Id, + SortOrder = index, + Name = field.Name, + Source = field.Source, + Format = field.Format, + Value = field.Value, + UpdatedUtc = updatedUtc + })); + } + + dbContext.MerchantProcessingFileProfileFieldRecords.AddRange(bodyFields); + dbContext.MerchantProcessingFileProfileHeaderFieldRecords.AddRange(headerFields); + dbContext.MerchantProcessingFileProfileTrailerFieldRecords.AddRange(trailerFields); + await dbContext.SaveChangesAsync(cancellationToken); + } + + private static async Task PersistContractsAsync( + MerchantFileProcessorDbContext dbContext, + IReadOnlyList contracts, + DateTimeOffset updatedUtc, + CancellationToken cancellationToken) + { + var records = contracts + .Select((contract, index) => new MerchantProcessingContractDefinitionRecord + { + SortOrder = index, + ContractId = contract.ContractId, + FileProfileId = contract.FileProfileId, + UpdatedUtc = updatedUtc + }) + .ToArray(); + + dbContext.MerchantProcessingContractDefinitionRecords.AddRange(records); + await dbContext.SaveChangesAsync(cancellationToken); + } + + private static async Task PersistMerchantsAsync( + MerchantFileProcessorDbContext dbContext, + IReadOnlyList merchants, + DateTimeOffset updatedUtc, + CancellationToken cancellationToken) + { + var merchantRecords = merchants + .Select((merchant, index) => + { + var runTimes = merchant.RunTimesUtc.Count > 0 ? merchant.RunTimesUtc : [merchant.RunAtUtc]; + return new MerchantProcessingMerchantRecord + { + SortOrder = index, + Name = merchant.Name, + Enabled = merchant.Enabled, + EstateId = merchant.EstateId, + MerchantId = merchant.MerchantId, + RunAtUtc = runTimes.FirstOrDefault() ?? merchant.RunAtUtc, + UpdatedUtc = updatedUtc + }; + }) + .ToArray(); + + dbContext.MerchantProcessingMerchantRecords.AddRange(merchantRecords); + await dbContext.SaveChangesAsync(cancellationToken); + + var runTimeRecords = new List(); + foreach (var merchantRecord in merchantRecords) + { + var merchant = merchants[merchantRecord.SortOrder]; + var runTimes = merchant.RunTimesUtc.Count > 0 ? merchant.RunTimesUtc : [merchant.RunAtUtc]; + + runTimeRecords.AddRange(runTimes.Select((runTime, index) => new MerchantProcessingMerchantRunTimeRecord + { + MerchantRecordId = merchantRecord.Id, + SortOrder = index, + RunTimeUtc = runTime, + UpdatedUtc = updatedUtc + })); + } + + dbContext.MerchantProcessingMerchantRunTimeRecords.AddRange(runTimeRecords); + await dbContext.SaveChangesAsync(cancellationToken); + } + + private static async Task ClearConfigurationTablesAsync(MerchantFileProcessorDbContext dbContext, CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM MerchantProcessingMerchantRunTimes;", cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM MerchantProcessingMerchants;", cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM MerchantProcessingFileProfileTrailerFields;", cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM MerchantProcessingFileProfileHeaderFields;", cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM MerchantProcessingFileProfileFields;", cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM MerchantProcessingFileProfiles;", cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM MerchantProcessingContractDefinitions;", cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM MerchantProcessingAuthenticationRecords;", cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM MerchantProcessingFileProcessingRecords;", cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM MerchantProcessingTransactionGenerationRecords;", cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync("DELETE FROM MerchantProcessingFileStatusPollingRecords;", cancellationToken); + } + + private static DateTimeOffset MaxUtc(params IEnumerable?[] sources) + { + var values = sources + .Where(source => source is not null) + .SelectMany(source => source!) + .ToArray(); + + return values.Length == 0 ? DateTimeOffset.UtcNow : values.Max(); + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingOptionsValidator.cs b/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingOptionsValidator.cs index 1e888a4..22feb77 100644 --- a/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingOptionsValidator.cs +++ b/TransactionProcessing.MerchantFileProcessor/Configuration/MerchantProcessingOptionsValidator.cs @@ -4,10 +4,6 @@ namespace TransactionProcessing.MerchantFileProcessor.Configuration; public static class MerchantProcessingOptionsValidator { public static bool Validate(MerchantProcessingOptions options) { - if (options.Merchants.Count == 0) { - return false; - } - if (string.IsNullOrWhiteSpace(options.Authentication.ClientId) || string.IsNullOrWhiteSpace(options.Authentication.ClientSecret)) { return false; } diff --git a/TransactionProcessing.MerchantFileProcessor/FileBuilding/TransactionFileGenerationService.cs b/TransactionProcessing.MerchantFileProcessor/FileBuilding/TransactionFileGenerationService.cs index 2d5ed5f..8ab1e11 100644 --- a/TransactionProcessing.MerchantFileProcessor/FileBuilding/TransactionFileGenerationService.cs +++ b/TransactionProcessing.MerchantFileProcessor/FileBuilding/TransactionFileGenerationService.cs @@ -22,7 +22,7 @@ GeneratedFile Build( } public sealed class TransactionFileGenerationService( - MerchantProcessingOptions options, + IMerchantProcessingConfigurationState configurationState, ITransactionGenerator transactionGenerator, IEnumerable builders) : ITransactionFileGenerationService { @@ -60,6 +60,7 @@ public GeneratedFile BuildFile(MerchantOptions merchant, ContractOptions contrac private bool TryResolveFileProfile(string contractId, out FileProfileOptions? fileProfile) { + var options = configurationState.Current; var contractDefinition = options.ContractDefinitions.FirstOrDefault(definition => definition.ContractId.Equals(contractId, StringComparison.OrdinalIgnoreCase)); diff --git a/TransactionProcessing.MerchantFileProcessor/FileBuilding/TransactionFileModels.cs b/TransactionProcessing.MerchantFileProcessor/FileBuilding/TransactionFileModels.cs index a07968d..76f7999 100644 --- a/TransactionProcessing.MerchantFileProcessor/FileBuilding/TransactionFileModels.cs +++ b/TransactionProcessing.MerchantFileProcessor/FileBuilding/TransactionFileModels.cs @@ -42,7 +42,7 @@ IReadOnlyList GenerateTransactions( } public sealed class RandomTransactionGenerator( - MerchantProcessingOptions options) : ITransactionGenerator + IMerchantProcessingConfigurationState configurationState) : ITransactionGenerator { public IReadOnlyList GenerateTransactions( MerchantOptions merchant, @@ -59,6 +59,7 @@ public IReadOnlyList GenerateTransactions( $"Contract '{contract.ContractId}' for merchant '{merchant.MerchantId}' does not contain any fixed-value products."); } + var options = configurationState.Current; var transactionCount = Random.Shared.Next( options.TransactionGeneration.MinimumTransactionsPerContract, options.TransactionGeneration.MaximumTransactionsPerContract + 1); diff --git a/TransactionProcessing.MerchantFileProcessor/FileStatusPollingWorker.cs b/TransactionProcessing.MerchantFileProcessor/FileStatusPollingWorker.cs index 3976fa8..0d0d7f5 100644 --- a/TransactionProcessing.MerchantFileProcessor/FileStatusPollingWorker.cs +++ b/TransactionProcessing.MerchantFileProcessor/FileStatusPollingWorker.cs @@ -5,7 +5,7 @@ namespace TransactionProcessing.MerchantFileProcessor; public sealed class FileStatusPollingWorker( - MerchantProcessingOptions options, + IMerchantProcessingConfigurationState configurationState, IAccessTokenProvider accessTokenProvider, IFileProcessingClient fileProcessingClient, IFileStatusStore fileStatusStore) : BackgroundService @@ -13,7 +13,7 @@ public sealed class FileStatusPollingWorker( protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var logger = LocalLogger.For(); - var pollInterval = options.FileStatusPolling.GetPollInterval(); + var pollInterval = configurationState.Current.FileStatusPolling.GetPollInterval(); logger.LogInformation($"File status polling worker started with interval of {pollInterval.TotalSeconds:0} seconds"); while (!stoppingToken.IsCancellationRequested) diff --git a/TransactionProcessing.MerchantFileProcessor/Persistence/MerchantFileProcessorDbContext.cs b/TransactionProcessing.MerchantFileProcessor/Persistence/MerchantFileProcessorDbContext.cs index 296c54f..1e3118d 100644 --- a/TransactionProcessing.MerchantFileProcessor/Persistence/MerchantFileProcessorDbContext.cs +++ b/TransactionProcessing.MerchantFileProcessor/Persistence/MerchantFileProcessorDbContext.cs @@ -8,6 +8,30 @@ public sealed class MerchantFileProcessorDbContext(DbContextOptions FileSendRecordLineStatuses => this.Set(); + public DbSet MerchantProcessingAuthenticationRecords => this.Set(); + + public DbSet MerchantProcessingFileProcessingRecords => this.Set(); + + public DbSet MerchantProcessingTransactionGenerationRecords => this.Set(); + + public DbSet MerchantProcessingFileStatusPollingRecords => this.Set(); + + public DbSet MerchantProcessingFileProfileRecords => this.Set(); + + public DbSet MerchantProcessingFileProfileFieldRecords => this.Set(); + + public DbSet MerchantProcessingFileProfileHeaderFieldRecords => this.Set(); + + public DbSet MerchantProcessingFileProfileTrailerFieldRecords => this.Set(); + + public DbSet MerchantProcessingContractDefinitionRecords => this.Set(); + + public DbSet MerchantProcessingMerchantRecords => this.Set(); + + public DbSet MerchantProcessingMerchantRunTimeRecords => this.Set(); + + public DbSet MerchantProcessingConfigurationRecords => this.Set(); + public DbSet MerchantRunRecords => this.Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -47,6 +71,135 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Cascade); }); + modelBuilder.Entity(entity => + { + entity.ToTable("MerchantProcessingAuthenticationRecords"); + entity.HasKey(record => record.Id); + entity.Property(record => record.ClientId).HasMaxLength(128).IsRequired(); + entity.Property(record => record.ClientSecret).HasMaxLength(256).IsRequired(); + entity.Property(record => record.Scope).HasMaxLength(512); + entity.Property(record => record.Audience).HasMaxLength(512); + entity.Property(record => record.UpdatedUtc); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("MerchantProcessingFileProcessingRecords"); + entity.HasKey(record => record.Id); + entity.Property(record => record.UserId).HasMaxLength(64).IsRequired(); + entity.Property(record => record.UpdatedUtc); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("MerchantProcessingTransactionGenerationRecords"); + entity.HasKey(record => record.Id); + entity.Property(record => record.UpdatedUtc); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("MerchantProcessingFileStatusPollingRecords"); + entity.HasKey(record => record.Id); + entity.Property(record => record.UpdatedUtc); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("MerchantProcessingFileProfiles"); + entity.HasKey(record => record.Id); + entity.Property(record => record.FileProfileId).HasMaxLength(128).IsRequired(); + entity.Property(record => record.FileProcessorFileProfileId).HasMaxLength(64).IsRequired(); + entity.Property(record => record.Format).HasMaxLength(32).IsRequired(); + entity.Property(record => record.FileExtension).HasMaxLength(32).IsRequired(); + entity.Property(record => record.FileNamePattern).HasMaxLength(260); + entity.Property(record => record.ContentType).HasMaxLength(128); + entity.Property(record => record.Delimiter).HasMaxLength(8); + entity.Property(record => record.RootPropertyName).HasMaxLength(128); + entity.Property(record => record.UpdatedUtc); + entity.HasIndex(record => record.FileProfileId).IsUnique(); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("MerchantProcessingFileProfileFields"); + entity.HasKey(record => record.Id); + entity.Property(record => record.Name).HasMaxLength(128).IsRequired(); + entity.Property(record => record.Source).HasMaxLength(128); + entity.Property(record => record.Format).HasMaxLength(64); + entity.Property(record => record.Value).HasMaxLength(256); + entity.Property(record => record.UpdatedUtc); + entity.HasIndex(record => new { record.FileProfileRecordId, record.SortOrder }); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("MerchantProcessingFileProfileHeaderFields"); + entity.HasKey(record => record.Id); + entity.Property(record => record.Name).HasMaxLength(128).IsRequired(); + entity.Property(record => record.Source).HasMaxLength(128); + entity.Property(record => record.Format).HasMaxLength(64); + entity.Property(record => record.Value).HasMaxLength(256); + entity.Property(record => record.UpdatedUtc); + entity.HasIndex(record => new { record.FileProfileRecordId, record.SortOrder }); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("MerchantProcessingFileProfileTrailerFields"); + entity.HasKey(record => record.Id); + entity.Property(record => record.Name).HasMaxLength(128).IsRequired(); + entity.Property(record => record.Source).HasMaxLength(128); + entity.Property(record => record.Format).HasMaxLength(64); + entity.Property(record => record.Value).HasMaxLength(256); + entity.Property(record => record.UpdatedUtc); + entity.HasIndex(record => new { record.FileProfileRecordId, record.SortOrder }); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("MerchantProcessingContractDefinitions"); + entity.HasKey(record => record.Id); + entity.Property(record => record.ContractId).HasMaxLength(64).IsRequired(); + entity.Property(record => record.FileProfileId).HasMaxLength(128).IsRequired(); + entity.Property(record => record.UpdatedUtc); + entity.HasIndex(record => record.ContractId).IsUnique(); + entity.HasIndex(record => record.FileProfileId); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("MerchantProcessingMerchants"); + entity.HasKey(record => record.Id); + entity.Property(record => record.Name).HasMaxLength(256).IsRequired(); + entity.Property(record => record.EstateId).HasMaxLength(64).IsRequired(); + entity.Property(record => record.MerchantId).HasMaxLength(64).IsRequired(); + entity.Property(record => record.RunAtUtc).HasMaxLength(16).IsRequired(); + entity.Property(record => record.UpdatedUtc); + entity.HasIndex(record => record.MerchantId).IsUnique(); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("MerchantProcessingMerchantRunTimes"); + entity.HasKey(record => record.Id); + entity.Property(record => record.RunTimeUtc).HasMaxLength(16).IsRequired(); + entity.Property(record => record.UpdatedUtc); + entity.HasIndex(record => new { record.MerchantRecordId, record.SortOrder }); + entity.HasOne() + .WithMany() + .HasForeignKey(record => record.MerchantRecordId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("MerchantProcessingConfigurationRecords"); + entity.HasKey(record => record.Id); + entity.Property(record => record.ConfigurationJson).IsRequired(); + entity.Property(record => record.UpdatedUtc); + }); + modelBuilder.Entity(entity => { entity.ToTable("MerchantRunRecords"); @@ -58,4 +211,4 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasIndex(record => new { record.MerchantId, record.ScheduledRunUtc, record.CompletedUtc }); }); } -} \ No newline at end of file +} diff --git a/TransactionProcessing.MerchantFileProcessor/Persistence/MerchantProcessingConfigurationEntities.cs b/TransactionProcessing.MerchantFileProcessor/Persistence/MerchantProcessingConfigurationEntities.cs new file mode 100644 index 0000000..468b6f9 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Persistence/MerchantProcessingConfigurationEntities.cs @@ -0,0 +1,145 @@ +namespace TransactionProcessing.MerchantFileProcessor.Persistence; + +public sealed class MerchantProcessingAuthenticationRecord +{ + public int Id { get; set; } + + public string ClientId { get; set; } = string.Empty; + + public string ClientSecret { get; set; } = string.Empty; + + public string? Scope { get; set; } + + public string? Audience { get; set; } + + public DateTimeOffset UpdatedUtc { get; set; } +} + +public sealed class MerchantProcessingFileProcessingRecord +{ + public int Id { get; set; } + + public string UserId { get; set; } = string.Empty; + + public DateTimeOffset UpdatedUtc { get; set; } +} + +public sealed class MerchantProcessingTransactionGenerationRecord +{ + public int Id { get; set; } + + public int MinimumTransactionsPerContract { get; set; } + + public int MaximumTransactionsPerContract { get; set; } + + public DateTimeOffset UpdatedUtc { get; set; } +} + +public sealed class MerchantProcessingFileStatusPollingRecord +{ + public int Id { get; set; } + + public int PollIntervalSeconds { get; set; } + + public DateTimeOffset UpdatedUtc { get; set; } +} + +public sealed class MerchantProcessingFileProfileRecord +{ + public int Id { get; set; } + + public int SortOrder { get; set; } + + public string FileProfileId { get; set; } = string.Empty; + + public string FileProcessorFileProfileId { get; set; } = string.Empty; + + public string Format { get; set; } = string.Empty; + + public string FileExtension { get; set; } = string.Empty; + + public string? FileNamePattern { get; set; } + + public string? ContentType { get; set; } + + public string? Delimiter { get; set; } + + public bool IncludeHeader { get; set; } + + public bool WriteIndented { get; set; } + + public string? RootPropertyName { get; set; } + + public DateTimeOffset UpdatedUtc { get; set; } +} + +public abstract class MerchantProcessingFileProfileFieldRecordBase +{ + public int Id { get; set; } + + public int FileProfileRecordId { get; set; } + + public int SortOrder { get; set; } + + public string Name { get; set; } = string.Empty; + + public string? Source { get; set; } + + public string? Format { get; set; } + + public string? Value { get; set; } + + public DateTimeOffset UpdatedUtc { get; set; } +} + +public sealed class MerchantProcessingFileProfileFieldRecord : MerchantProcessingFileProfileFieldRecordBase; + +public sealed class MerchantProcessingFileProfileHeaderFieldRecord : MerchantProcessingFileProfileFieldRecordBase; + +public sealed class MerchantProcessingFileProfileTrailerFieldRecord : MerchantProcessingFileProfileFieldRecordBase; + +public sealed class MerchantProcessingContractDefinitionRecord +{ + public int Id { get; set; } + + public int SortOrder { get; set; } + + public string ContractId { get; set; } = string.Empty; + + public string FileProfileId { get; set; } = string.Empty; + + public DateTimeOffset UpdatedUtc { get; set; } +} + +public sealed class MerchantProcessingMerchantRecord +{ + public int Id { get; set; } + + public int SortOrder { get; set; } + + public string Name { get; set; } = string.Empty; + + public bool Enabled { get; set; } = true; + + public string EstateId { get; set; } = string.Empty; + + public string MerchantId { get; set; } = string.Empty; + + public string RunAtUtc { get; set; } = "02:00:00"; + + public DateTimeOffset UpdatedUtc { get; set; } +} + +public sealed class MerchantProcessingMerchantRunTimeRecord +{ + public int Id { get; set; } + + public int MerchantRecordId { get; set; } + + public int SortOrder { get; set; } + + public string RunTimeUtc { get; set; } = string.Empty; + + public DateTimeOffset UpdatedUtc { get; set; } +} + diff --git a/TransactionProcessing.MerchantFileProcessor/Persistence/MerchantProcessingConfigurationRecord.cs b/TransactionProcessing.MerchantFileProcessor/Persistence/MerchantProcessingConfigurationRecord.cs new file mode 100644 index 0000000..80f0cc2 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Persistence/MerchantProcessingConfigurationRecord.cs @@ -0,0 +1,10 @@ +namespace TransactionProcessing.MerchantFileProcessor.Persistence; + +public sealed class MerchantProcessingConfigurationRecord +{ + public int Id { get; set; } + + public string ConfigurationJson { get; set; } = string.Empty; + + public DateTimeOffset UpdatedUtc { get; set; } +} diff --git a/TransactionProcessing.MerchantFileProcessor/Program.cs b/TransactionProcessing.MerchantFileProcessor/Program.cs index 28d4a16..f240934 100644 --- a/TransactionProcessing.MerchantFileProcessor/Program.cs +++ b/TransactionProcessing.MerchantFileProcessor/Program.cs @@ -41,6 +41,7 @@ .SetBasePath(contentRoot) .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true) + .AddJsonFile("merchant-processing.bootstrap.json", optional: true, reloadOnChange: true) .AddJsonFile("hosting.json", optional: true, reloadOnChange: true) .AddEnvironmentVariables() .AddCommandLine(args); @@ -64,17 +65,9 @@ options.ServiceName = "Merchant File Processor"; }); - var merchantProcessingOptions = builder.Configuration - .GetSection(MerchantProcessingOptions.SectionName) - .Get() ?? new MerchantProcessingOptions(); - - if (!MerchantProcessingOptionsValidator.Validate(merchantProcessingOptions)) - { - throw new InvalidOperationException("MerchantProcessing configuration is invalid."); - } - - builder.Services.AddSingleton(merchantProcessingOptions); builder.Services.AddSingleton(frameworkLoggingOptions); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton>(sp => { @@ -94,12 +87,18 @@ }; }); - var connectionString = BuildConnectionString( - builder.Configuration.GetConnectionString("MerchantFileProcessor"), - contentRoot); + var configuredConnectionString = builder.Configuration.GetConnectionString("MerchantFileProcessor"); + + if (string.IsNullOrWhiteSpace(configuredConnectionString)) + { + throw new InvalidOperationException("ConnectionStrings:MerchantFileProcessor must be configured."); + } + + var connectionString = BuildConnectionString(configuredConnectionString, contentRoot); builder.Services.AddHttpClient(); builder.Services.AddHttpContextAccessor(); + builder.Services.AddRazorComponents(); builder.Services.AddDbContextFactory(options => options.UseSqlite(connectionString)); builder.Services.RegisterHttpClient(); @@ -124,6 +123,7 @@ builder.Services.AddSingleton>(_ => (str, type) => StringSerialiser.DeserializeObject(str, type)); var serialiserSettings = SystemTextJsonSerializer.GetDefaultJsonSerializerOptions(); builder.Services.AddSingleton(serialiserSettings); + builder.Services.AddSingleton(); var app = builder.Build(); @@ -139,6 +139,9 @@ await using var dbContext = await dbContextFactory.CreateDbContextAsync(); await dbContext.Database.EnsureCreatedAsync(); await EnsurePersistenceSchemaAsync(dbContext, CancellationToken.None); + + var configurationStore = scope.ServiceProvider.GetRequiredService(); + await configurationStore.GetCurrentSnapshotAsync(CancellationToken.None); } var appSettingsPath = Path.Combine(contentRoot, "appsettings.json"); @@ -147,7 +150,11 @@ SharedLogger.LogWarning($"appsettings.json was not found at {appSettingsPath}. Using environment-specific configuration and environment variables."); } + app.UseStaticFiles(); + app.UseAntiforgery(); app.MapReportingEndpoints(); + app.MapConfigurationManagementEndpoints(); + app.MapRazorComponents(); await app.RunAsync(); } @@ -161,18 +168,19 @@ LogManager.Shutdown(); } -static string BuildConnectionString(string? configuredConnectionString, string contentRoot) +static string BuildConnectionString(string configuredConnectionString, string contentRoot) { - if (string.IsNullOrWhiteSpace(configuredConnectionString)) + const string dataSourcePrefix = "Data Source="; + + if (!configuredConnectionString.StartsWith(dataSourcePrefix, StringComparison.OrdinalIgnoreCase) && + !Path.IsPathRooted(configuredConnectionString)) { - return $"Data Source={Path.Combine(contentRoot, "merchant-file-processor.db")}"; + configuredConnectionString = Path.Combine(contentRoot, configuredConnectionString); } - const string dataSourcePrefix = "Data Source="; - if (!configuredConnectionString.StartsWith(dataSourcePrefix, StringComparison.OrdinalIgnoreCase)) { - return configuredConnectionString; + return $"{dataSourcePrefix}{configuredConnectionString}"; } var filePath = configuredConnectionString[dataSourcePrefix.Length..].Trim(); @@ -313,7 +321,195 @@ CompletedUtc TEXT NOT NULL """, cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS MerchantProcessingAuthenticationRecords ( + Id INTEGER NOT NULL CONSTRAINT PK_MerchantProcessingAuthenticationRecords PRIMARY KEY, + ClientId TEXT NOT NULL, + ClientSecret TEXT NOT NULL, + Scope TEXT NULL, + Audience TEXT NULL, + UpdatedUtc TEXT NOT NULL + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS MerchantProcessingFileProcessingRecords ( + Id INTEGER NOT NULL CONSTRAINT PK_MerchantProcessingFileProcessingRecords PRIMARY KEY, + UserId TEXT NOT NULL, + UpdatedUtc TEXT NOT NULL + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS MerchantProcessingTransactionGenerationRecords ( + Id INTEGER NOT NULL CONSTRAINT PK_MerchantProcessingTransactionGenerationRecords PRIMARY KEY, + MinimumTransactionsPerContract INTEGER NOT NULL, + MaximumTransactionsPerContract INTEGER NOT NULL, + UpdatedUtc TEXT NOT NULL + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS MerchantProcessingFileStatusPollingRecords ( + Id INTEGER NOT NULL CONSTRAINT PK_MerchantProcessingFileStatusPollingRecords PRIMARY KEY, + PollIntervalSeconds INTEGER NOT NULL, + UpdatedUtc TEXT NOT NULL + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS MerchantProcessingFileProfiles ( + Id INTEGER NOT NULL CONSTRAINT PK_MerchantProcessingFileProfiles PRIMARY KEY AUTOINCREMENT, + SortOrder INTEGER NOT NULL, + FileProfileId TEXT NOT NULL, + FileProcessorFileProfileId TEXT NOT NULL, + Format TEXT NOT NULL, + FileExtension TEXT NOT NULL, + FileNamePattern TEXT NULL, + ContentType TEXT NULL, + Delimiter TEXT NULL, + IncludeHeader INTEGER NOT NULL, + WriteIndented INTEGER NOT NULL, + RootPropertyName TEXT NULL, + UpdatedUtc TEXT NOT NULL + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS MerchantProcessingFileProfileFields ( + Id INTEGER NOT NULL CONSTRAINT PK_MerchantProcessingFileProfileFields PRIMARY KEY AUTOINCREMENT, + FileProfileRecordId INTEGER NOT NULL, + SortOrder INTEGER NOT NULL, + Name TEXT NOT NULL, + Source TEXT NULL, + Format TEXT NULL, + Value TEXT NULL, + UpdatedUtc TEXT NOT NULL, + CONSTRAINT FK_MerchantProcessingFileProfileFields_MerchantProcessingFileProfiles_FileProfileRecordId + FOREIGN KEY (FileProfileRecordId) REFERENCES MerchantProcessingFileProfiles (Id) ON DELETE CASCADE + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS MerchantProcessingFileProfileHeaderFields ( + Id INTEGER NOT NULL CONSTRAINT PK_MerchantProcessingFileProfileHeaderFields PRIMARY KEY AUTOINCREMENT, + FileProfileRecordId INTEGER NOT NULL, + SortOrder INTEGER NOT NULL, + Name TEXT NOT NULL, + Source TEXT NULL, + Format TEXT NULL, + Value TEXT NULL, + UpdatedUtc TEXT NOT NULL, + CONSTRAINT FK_MerchantProcessingFileProfileHeaderFields_MerchantProcessingFileProfiles_FileProfileRecordId + FOREIGN KEY (FileProfileRecordId) REFERENCES MerchantProcessingFileProfiles (Id) ON DELETE CASCADE + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS MerchantProcessingFileProfileTrailerFields ( + Id INTEGER NOT NULL CONSTRAINT PK_MerchantProcessingFileProfileTrailerFields PRIMARY KEY AUTOINCREMENT, + FileProfileRecordId INTEGER NOT NULL, + SortOrder INTEGER NOT NULL, + Name TEXT NOT NULL, + Source TEXT NULL, + Format TEXT NULL, + Value TEXT NULL, + UpdatedUtc TEXT NOT NULL, + CONSTRAINT FK_MerchantProcessingFileProfileTrailerFields_MerchantProcessingFileProfiles_FileProfileRecordId + FOREIGN KEY (FileProfileRecordId) REFERENCES MerchantProcessingFileProfiles (Id) ON DELETE CASCADE + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS MerchantProcessingContractDefinitions ( + Id INTEGER NOT NULL CONSTRAINT PK_MerchantProcessingContractDefinitions PRIMARY KEY AUTOINCREMENT, + SortOrder INTEGER NOT NULL, + ContractId TEXT NOT NULL, + FileProfileId TEXT NOT NULL, + UpdatedUtc TEXT NOT NULL + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS MerchantProcessingMerchants ( + Id INTEGER NOT NULL CONSTRAINT PK_MerchantProcessingMerchants PRIMARY KEY AUTOINCREMENT, + SortOrder INTEGER NOT NULL, + Name TEXT NOT NULL, + Enabled INTEGER NOT NULL, + EstateId TEXT NOT NULL, + MerchantId TEXT NOT NULL, + RunAtUtc TEXT NOT NULL, + UpdatedUtc TEXT NOT NULL + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS MerchantProcessingMerchantRunTimes ( + Id INTEGER NOT NULL CONSTRAINT PK_MerchantProcessingMerchantRunTimes PRIMARY KEY AUTOINCREMENT, + MerchantRecordId INTEGER NOT NULL, + SortOrder INTEGER NOT NULL, + RunTimeUtc TEXT NOT NULL, + UpdatedUtc TEXT NOT NULL, + CONSTRAINT FK_MerchantProcessingMerchantRunTimes_MerchantProcessingMerchants_MerchantRecordId + FOREIGN KEY (MerchantRecordId) REFERENCES MerchantProcessingMerchants (Id) ON DELETE CASCADE + ); + """, + cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS MerchantProcessingConfigurationRecords ( + Id INTEGER NOT NULL CONSTRAINT PK_MerchantProcessingConfigurationRecords PRIMARY KEY AUTOINCREMENT, + ConfigurationJson TEXT NOT NULL, + UpdatedUtc TEXT NOT NULL + ); + """, + cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync( "CREATE INDEX IF NOT EXISTS IX_MerchantRunRecords_MerchantId_ScheduledRunUtc_CompletedUtc ON MerchantRunRecords (MerchantId, ScheduledRunUtc, CompletedUtc);", cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync( + "CREATE UNIQUE INDEX IF NOT EXISTS IX_MerchantProcessingFileProfiles_FileProfileId ON MerchantProcessingFileProfiles (FileProfileId);", + cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync( + "CREATE INDEX IF NOT EXISTS IX_MerchantProcessingFileProfileFields_FileProfileRecordId_SortOrder ON MerchantProcessingFileProfileFields (FileProfileRecordId, SortOrder);", + cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync( + "CREATE INDEX IF NOT EXISTS IX_MerchantProcessingFileProfileHeaderFields_FileProfileRecordId_SortOrder ON MerchantProcessingFileProfileHeaderFields (FileProfileRecordId, SortOrder);", + cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync( + "CREATE INDEX IF NOT EXISTS IX_MerchantProcessingFileProfileTrailerFields_FileProfileRecordId_SortOrder ON MerchantProcessingFileProfileTrailerFields (FileProfileRecordId, SortOrder);", + cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync( + "CREATE UNIQUE INDEX IF NOT EXISTS IX_MerchantProcessingContractDefinitions_ContractId ON MerchantProcessingContractDefinitions (ContractId);", + cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync( + "CREATE UNIQUE INDEX IF NOT EXISTS IX_MerchantProcessingMerchants_MerchantId ON MerchantProcessingMerchants (MerchantId);", + cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync( + "CREATE INDEX IF NOT EXISTS IX_MerchantProcessingMerchantRunTimes_MerchantRecordId_SortOrder ON MerchantProcessingMerchantRunTimes (MerchantRecordId, SortOrder);", + cancellationToken); } diff --git a/TransactionProcessing.MerchantFileProcessor/Reporting/ConfigurationManagementEndpointRouteBuilderExtensions.cs b/TransactionProcessing.MerchantFileProcessor/Reporting/ConfigurationManagementEndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000..23e3ef8 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Reporting/ConfigurationManagementEndpointRouteBuilderExtensions.cs @@ -0,0 +1,409 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using TransactionProcessing.MerchantFileProcessor.Configuration; +using Microsoft.Extensions.Primitives; + +namespace TransactionProcessing.MerchantFileProcessor.Reporting; + +public static class ConfigurationManagementEndpointRouteBuilderExtensions +{ + public static IEndpointRouteBuilder MapConfigurationManagementEndpoints(this IEndpointRouteBuilder endpoints) + { + endpoints.MapPost("/api/configuration/runtime", async ( + HttpRequest request, + IMerchantProcessingConfigurationStore configurationStore, + CancellationToken cancellationToken) => + { + var form = await request.ReadFormAsync(cancellationToken); + var returnUrl = GetReturnUrl(form, "/ops/config/runtime"); + var snapshot = await configurationStore.GetCurrentSnapshotAsync(cancellationToken); + var options = Clone(snapshot.Options); + options = new MerchantProcessingOptions + { + Authentication = new AuthenticationOptions + { + ClientId = form["ClientId"].ToString(), + ClientSecret = form["ClientSecret"].ToString(), + Scope = GetOptional(form, "Scope"), + Audience = GetOptional(form, "Audience") + }, + FileProcessing = new FileProcessingOptions + { + UserId = form["UserId"].ToString() + }, + TransactionGeneration = new TransactionGenerationOptions + { + MinimumTransactionsPerContract = ParseInt(form["MinimumTransactionsPerContract"], 5), + MaximumTransactionsPerContract = ParseInt(form["MaximumTransactionsPerContract"], 25) + }, + FileStatusPolling = new FileStatusPollingOptions + { + PollIntervalSeconds = ParseInt(form["PollIntervalSeconds"], 30) + }, + ContractDefinitions = options.ContractDefinitions, + FileProfiles = options.FileProfiles, + Merchants = options.Merchants + }; + + try + { + await configurationStore.SaveAsync(options, cancellationToken); + return Results.Redirect($"{returnUrl}?saved=1"); + } + catch (Exception ex) + { + return Results.Redirect($"{returnUrl}?error={Uri.EscapeDataString(ex.Message)}"); + } + }); + + endpoints.MapPost("/api/configuration/merchants", async ( + HttpRequest request, + IMerchantProcessingConfigurationStore configurationStore, + CancellationToken cancellationToken) => + { + var form = await request.ReadFormAsync(cancellationToken); + var returnUrl = GetReturnUrl(form, "/ops/config/merchants"); + var snapshot = await configurationStore.GetCurrentSnapshotAsync(cancellationToken); + var options = Clone(snapshot.Options); + var originalMerchantId = GetOptional(form, "OriginalMerchantId"); + var merchantId = GetRequired(form, "MerchantId"); + var merchantIndex = options.Merchants.FindIndex(entry => + string.Equals(entry.MerchantId, originalMerchantId ?? merchantId, StringComparison.OrdinalIgnoreCase)); + + var merchant = new MerchantOptions + { + Name = GetRequired(form, "Name"), + Enabled = ParseBool(form["Enabled"]), + EstateId = GetRequired(form, "EstateId"), + MerchantId = merchantId, + RunAtUtc = GetOptional(form, "RunAtUtc") ?? "02:00:00", + RunTimesUtc = ParseRunTimes(GetOptional(form, "RunTimesUtc")) + }; + + if (merchantIndex >= 0) + { + options.Merchants[merchantIndex] = merchant; + } + else + { + options.Merchants.Add(merchant); + } + + try + { + await configurationStore.SaveAsync(options, cancellationToken); + return Results.Redirect($"{returnUrl}?saved=1"); + } + catch (Exception ex) + { + return Results.Redirect($"{returnUrl}?error={Uri.EscapeDataString(ex.Message)}"); + } + }); + + endpoints.MapPost("/api/configuration/contracts", async ( + HttpRequest request, + IMerchantProcessingConfigurationStore configurationStore, + CancellationToken cancellationToken) => + { + var form = await request.ReadFormAsync(cancellationToken); + var returnUrl = GetReturnUrl(form, "/ops/config/contracts"); + var snapshot = await configurationStore.GetCurrentSnapshotAsync(cancellationToken); + var options = Clone(snapshot.Options); + var originalContractId = GetOptional(form, "OriginalContractId"); + var contractId = GetRequired(form, "ContractId"); + var contractIndex = options.ContractDefinitions.FindIndex(entry => + string.Equals(entry.ContractId, originalContractId ?? contractId, StringComparison.OrdinalIgnoreCase)); + + var contract = new ContractDefinitionOptions + { + ContractId = contractId, + FileProfileId = GetRequired(form, "FileProfileId") + }; + + if (contractIndex >= 0) + { + options.ContractDefinitions[contractIndex] = contract; + } + else + { + options.ContractDefinitions.Add(contract); + } + + try + { + await configurationStore.SaveAsync(options, cancellationToken); + return Results.Redirect($"{returnUrl}?saved=1"); + } + catch (Exception ex) + { + return Results.Redirect($"{returnUrl}?error={Uri.EscapeDataString(ex.Message)}"); + } + }); + + endpoints.MapPost("/api/configuration/file-profiles", async ( + HttpRequest request, + IMerchantProcessingConfigurationStore configurationStore, + CancellationToken cancellationToken) => + { + var form = await request.ReadFormAsync(cancellationToken); + var returnUrl = GetReturnUrl(form, "/ops/config/file-profiles"); + var snapshot = await configurationStore.GetCurrentSnapshotAsync(cancellationToken); + var options = Clone(snapshot.Options); + var originalFileProfileId = GetOptional(form, "OriginalFileProfileId"); + var fileProfileId = GetRequired(form, "FileProfileId"); + var profileIndex = options.FileProfiles.FindIndex(entry => + string.Equals(entry.FileProfileId, originalFileProfileId ?? fileProfileId, StringComparison.OrdinalIgnoreCase)); + + var existingProfile = profileIndex >= 0 ? options.FileProfiles[profileIndex] : new FileProfileOptions(); + var profile = new FileProfileOptions + { + FileProfileId = fileProfileId, + FileProcessorFileProfileId = GetRequired(form, "FileProcessorFileProfileId"), + Format = GetRequired(form, "Format"), + FileExtension = GetRequired(form, "FileExtension"), + FileNamePattern = GetOptional(form, "FileNamePattern"), + ContentType = GetOptional(form, "ContentType"), + Delimited = new DelimitedFileProfileOptions + { + Delimiter = GetOptional(form, "Delimiter") ?? ",", + IncludeHeader = ParseBool(form["IncludeHeader"]), + HeaderFields = existingProfile.Delimited.HeaderFields, + TrailerFields = existingProfile.Delimited.TrailerFields + }, + Json = new JsonFileProfileOptions + { + WriteIndented = ParseBool(form["WriteIndented"]), + RootPropertyName = GetOptional(form, "RootPropertyName") + }, + Fields = existingProfile.Fields + }; + + if (profileIndex >= 0) + { + options.FileProfiles[profileIndex] = profile; + } + else + { + options.FileProfiles.Add(profile); + } + + try + { + await configurationStore.SaveAsync(options, cancellationToken); + return Results.Redirect($"{returnUrl}?saved=1"); + } + catch (Exception ex) + { + return Results.Redirect($"{returnUrl}?error={Uri.EscapeDataString(ex.Message)}"); + } + }); + + endpoints.MapPost("/api/configuration/file-profiles/{fileProfileId}/fields/{section}", async ( + string fileProfileId, + string section, + HttpRequest request, + IMerchantProcessingConfigurationStore configurationStore, + JsonSerializerOptions jsonSerializerOptions, + CancellationToken cancellationToken) => + { + var form = await request.ReadFormAsync(cancellationToken); + var returnUrl = GetReturnUrl(form, $"/ops/config/file-profiles/{Uri.EscapeDataString(fileProfileId)}"); + var snapshot = await configurationStore.GetCurrentSnapshotAsync(cancellationToken); + var options = Clone(snapshot.Options); + var profileIndex = options.FileProfiles.FindIndex(entry => entry.FileProfileId.Equals(fileProfileId, StringComparison.OrdinalIgnoreCase)); + + if (profileIndex < 0) + { + return Results.Redirect($"{returnUrl}?error={Uri.EscapeDataString("File profile not found.")}"); + } + + var profile = options.FileProfiles[profileIndex]; + var fields = GetFieldList(profile, section); + var originalSortOrder = ParseNullableInt(form["OriginalSortOrder"]); + + if (ParseBool(form["Delete"])) + { + if (originalSortOrder.HasValue) + { + if (originalSortOrder.Value >= 0 && originalSortOrder.Value < fields.Count) + { + fields.RemoveAt(originalSortOrder.Value); + } + } + } + else + { + var field = originalSortOrder.HasValue + ? (originalSortOrder.Value >= 0 && originalSortOrder.Value < fields.Count ? fields[originalSortOrder.Value] : null) + : null; + + if (field is null) + { + field = new FileFieldOptions(); + } + + var updatedField = new FileFieldOptions + { + Name = GetRequired(form, "Name"), + Source = GetOptional(form, "Source") ?? string.Empty, + Format = GetOptional(form, "Format"), + Value = GetOptional(form, "Value") + }; + + if (originalSortOrder.HasValue && originalSortOrder.Value >= 0 && originalSortOrder.Value < fields.Count) + { + fields[originalSortOrder.Value] = updatedField; + } + else + { + fields.Add(updatedField); + } + } + + try + { + await configurationStore.SaveAsync(options, cancellationToken); + return Results.Redirect($"{returnUrl}?saved=1"); + } + catch (Exception ex) + { + return Results.Redirect($"{returnUrl}?error={Uri.EscapeDataString(ex.Message)}"); + } + }); + + return endpoints; + } + + private static List GetFieldList(FileProfileOptions profile, string section) + { + return section.ToLowerInvariant() switch + { + "body" or "fields" => profile.Fields, + "header" => profile.Delimited.HeaderFields, + "trailer" => profile.Delimited.TrailerFields, + _ => throw new InvalidOperationException("Unknown file profile field section.") + }; + } + + private static MerchantProcessingOptions Clone(MerchantProcessingOptions options) => + new() + { + Authentication = new AuthenticationOptions + { + ClientId = options.Authentication.ClientId, + ClientSecret = options.Authentication.ClientSecret, + Scope = options.Authentication.Scope, + Audience = options.Authentication.Audience + }, + FileProcessing = new FileProcessingOptions + { + UserId = options.FileProcessing.UserId + }, + TransactionGeneration = new TransactionGenerationOptions + { + MinimumTransactionsPerContract = options.TransactionGeneration.MinimumTransactionsPerContract, + MaximumTransactionsPerContract = options.TransactionGeneration.MaximumTransactionsPerContract + }, + FileStatusPolling = new FileStatusPollingOptions + { + PollIntervalSeconds = options.FileStatusPolling.PollIntervalSeconds + }, + ContractDefinitions = options.ContractDefinitions + .Select(contract => new ContractDefinitionOptions + { + ContractId = contract.ContractId, + FileProfileId = contract.FileProfileId + }) + .ToList(), + FileProfiles = options.FileProfiles + .Select(profile => new FileProfileOptions + { + FileProfileId = profile.FileProfileId, + FileProcessorFileProfileId = profile.FileProcessorFileProfileId, + Format = profile.Format, + FileExtension = profile.FileExtension, + FileNamePattern = profile.FileNamePattern, + ContentType = profile.ContentType, + Delimited = new DelimitedFileProfileOptions + { + Delimiter = profile.Delimited.Delimiter, + IncludeHeader = profile.Delimited.IncludeHeader, + HeaderFields = profile.Delimited.HeaderFields + .Select(field => new FileFieldOptions + { + Name = field.Name, + Source = field.Source, + Format = field.Format, + Value = field.Value + }) + .ToList(), + TrailerFields = profile.Delimited.TrailerFields + .Select(field => new FileFieldOptions + { + Name = field.Name, + Source = field.Source, + Format = field.Format, + Value = field.Value + }) + .ToList() + }, + Json = new JsonFileProfileOptions + { + WriteIndented = profile.Json.WriteIndented, + RootPropertyName = profile.Json.RootPropertyName + }, + Fields = profile.Fields + .Select(field => new FileFieldOptions + { + Name = field.Name, + Source = field.Source, + Format = field.Format, + Value = field.Value + }) + .ToList() + }) + .ToList(), + Merchants = options.Merchants + .Select(merchant => new MerchantOptions + { + Name = merchant.Name, + Enabled = merchant.Enabled, + EstateId = merchant.EstateId, + MerchantId = merchant.MerchantId, + RunAtUtc = merchant.RunAtUtc, + RunTimesUtc = merchant.RunTimesUtc.ToList() + }) + .ToList() + }; + + private static string GetReturnUrl(IFormCollection form, string defaultValue) => + string.IsNullOrWhiteSpace(form["ReturnUrl"]) ? defaultValue : form["ReturnUrl"].ToString(); + + private static string GetRequired(IFormCollection form, string key) => + form[key].ToString().Trim(); + + private static string? GetOptional(IFormCollection form, string key) + { + var value = form[key].ToString().Trim(); + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + private static bool ParseBool(StringValues value) => + value.Count > 0 && bool.TryParse(value.ToString(), out var parsed) && parsed; + + private static int ParseInt(StringValues value, int defaultValue) => + int.TryParse(value.ToString(), out var parsed) ? parsed : defaultValue; + + private static int? ParseNullableInt(StringValues value) => + int.TryParse(value.ToString(), out var parsed) ? parsed : null; + + private static List ParseRunTimes(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return []; + } + + return value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/Reporting/FileStatusReportService.cs b/TransactionProcessing.MerchantFileProcessor/Reporting/FileStatusReportService.cs index 8f064b5..16af43a 100644 --- a/TransactionProcessing.MerchantFileProcessor/Reporting/FileStatusReportService.cs +++ b/TransactionProcessing.MerchantFileProcessor/Reporting/FileStatusReportService.cs @@ -11,6 +11,10 @@ public interface IFileStatusReportService { Task GetReportAsync(CancellationToken cancellationToken); + Task GetMerchantReportAsync(string merchantId, CancellationToken cancellationToken); + + Task GetFileReportAsync(string merchantId, long fileId, CancellationToken cancellationToken); + Task RenderHtmlAsync(CancellationToken cancellationToken); Task RenderMerchantHtmlAsync(string merchantId, CancellationToken cancellationToken); @@ -64,7 +68,7 @@ public sealed record FileLineStatusRow(int LineNumber, public sealed class FileStatusReportService( IDbContextFactory dbContextFactory, - MerchantProcessingOptions options) : IFileStatusReportService + IMerchantProcessingConfigurationState configurationState) : IFileStatusReportService { public async Task GetReportAsync(CancellationToken cancellationToken) { @@ -92,6 +96,7 @@ public async Task GetReportAsync(CancellationToken cancellatio var aggregateLookup = aggregates.ToDictionary(item => item.MerchantId, StringComparer.OrdinalIgnoreCase); + var options = configurationState.Current; var merchantSummaries = options.Merchants .OrderBy(merchant => merchant.MerchantId, StringComparer.OrdinalIgnoreCase) .Select(merchant => @@ -264,7 +269,7 @@ public async Task RenderHtmlAsync(CancellationToken cancellationToken) return html.ToString(); } - private async Task GetMerchantReportAsync(string merchantId, CancellationToken cancellationToken) + public async Task GetMerchantReportAsync(string merchantId, CancellationToken cancellationToken) { var summary = (await this.GetReportAsync(cancellationToken)) .MerchantSummaries @@ -303,7 +308,7 @@ public async Task RenderHtmlAsync(CancellationToken cancellationToken) return new MerchantDetailReport(DateTimeOffset.UtcNow, summary, recentFiles); } - private async Task GetFileReportAsync(string merchantId, long fileId, CancellationToken cancellationToken) + public async Task GetFileReportAsync(string merchantId, long fileId, CancellationToken cancellationToken) { var merchantReport = await this.GetMerchantReportAsync(merchantId, cancellationToken); if (merchantReport is null) diff --git a/TransactionProcessing.MerchantFileProcessor/Reporting/OperationsDashboardService.cs b/TransactionProcessing.MerchantFileProcessor/Reporting/OperationsDashboardService.cs new file mode 100644 index 0000000..2fb9748 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Reporting/OperationsDashboardService.cs @@ -0,0 +1,755 @@ +using System.Net; +using System.Text; +using Microsoft.EntityFrameworkCore; +using TransactionProcessing.MerchantFileProcessor.Configuration; +using TransactionProcessing.MerchantFileProcessor.Persistence; +using TransactionProcessing.MerchantFileProcessor.Services; + +namespace TransactionProcessing.MerchantFileProcessor.Reporting; + +public interface IOperationsDashboardService +{ + Task GetDashboardModelAsync(CancellationToken cancellationToken); + + Task RenderDashboardHtmlAsync(CancellationToken cancellationToken); + + Task RenderConfigurationHtmlAsync(CancellationToken cancellationToken); + + Task RenderRunsHtmlAsync(CancellationToken cancellationToken); +} + +public sealed record OperationsMetric(string Label, string Value, string Detail); + +public sealed record MerchantOperationsRow( + string MerchantId, + string MerchantName, + bool Enabled, + string EstateId, + string RunTimesUtc, + DateTimeOffset? LastMerchantRunUtc, + DateTimeOffset? NextScheduledUtc, + int SuccessfulFiles, + int FailedFiles, + int PendingStatusChecks); + +public sealed record FileProfileRow( + string FileProfileId, + string FileProcessorFileProfileId, + string Format, + string FileExtension, + string? ContentType, + string LayoutSummary, + int FieldCount, + int HeaderFieldCount, + int TrailerFieldCount, + string FieldMap); + +public sealed record ContractMappingRow( + string ContractId, + string FileProfileId); + +public sealed record RunHistoryRow( + string MerchantId, + string MerchantName, + DateTimeOffset ScheduledRunUtc, + DateTimeOffset CompletedUtc, + string Status, + string? ErrorMessage); + +public sealed record OperationsDashboardModel( + DateTimeOffset GeneratedUtc, + string EnvironmentName, + string ConnectionStringSummary, + string AuthenticationSummary, + string FileProcessingSummary, + string TransactionGenerationSummary, + string PollingSummary, + string LoggingSummary, + IReadOnlyList Metrics, + IReadOnlyList Merchants, + IReadOnlyList FileProfiles, + IReadOnlyList Contracts, + IReadOnlyList RecentRuns); + +public sealed class OperationsDashboardService( + IDbContextFactory dbContextFactory, + IMerchantProcessingConfigurationState configurationState, + FrameworkLoggingOptions frameworkLoggingOptions, + IHostEnvironment hostEnvironment, + IConfiguration configuration) : IOperationsDashboardService +{ + public Task GetDashboardModelAsync(CancellationToken cancellationToken) => + this.BuildDashboardModelAsync(cancellationToken); + + public async Task RenderDashboardHtmlAsync(CancellationToken cancellationToken) + { + var model = await this.BuildDashboardModelAsync(cancellationToken); + var html = new StringBuilder(); + + AppendDocumentStart(html, "Merchant File Processor Operations", "Management and operations"); + AppendNavigation(html, ("Overview", "/ops"), ("Configuration", "/ops/config"), ("Run history", "/ops/runs"), ("Status board", "/status")); + + html.AppendLine("
"); + html.AppendLine("
"); + html.AppendLine("
Operations console
"); + html.AppendLine("

Merchant File Processor

"); + html.AppendLine("

Live configuration, scheduling, and processing visibility for the worker, using the same runtime settings that power the service.

"); + html.AppendLine("
"); + html.AppendLine("
"); + html.AppendLine($"
Environment
{Encode(model.EnvironmentName)}
"); + html.AppendLine($"
Generated
{Encode(model.GeneratedUtc.ToString("u"))}
"); + html.AppendLine($"
Connection
{Encode(model.ConnectionStringSummary)}
"); + html.AppendLine("
"); + html.AppendLine("
"); + + AppendMetrics(html, model.Metrics); + + html.AppendLine("
"); + html.AppendLine("
Merchant schedule
"); + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine(" "); + + foreach (var merchant in model.Merchants) + { + html.AppendLine(" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine(" "); + } + + if (model.Merchants.Count == 0) + { + html.AppendLine(" "); + } + + html.AppendLine(" "); + html.AppendLine("
MerchantEnabledEstateRun times (UTC)Last merchant runNext runSuccessFailedPending checksStatus
{Encode(merchant.MerchantName)}
{Encode(merchant.MerchantId)}
{RenderBadge(merchant.Enabled ? "Enabled" : "Disabled", merchant.Enabled ? "good" : "neutral")}{Encode(merchant.EstateId)}{Encode(merchant.RunTimesUtc)}{Encode(merchant.LastMerchantRunUtc?.ToString("u") ?? "Never")}{Encode(merchant.NextScheduledUtc?.ToString("u") ?? "Disabled")}{merchant.SuccessfulFiles}{merchant.FailedFiles}{merchant.PendingStatusChecks}View status
No merchants are configured.
"); + html.AppendLine("
"); + + html.AppendLine("
"); + AppendFileProfilesSection(html, model.FileProfiles); + AppendContractsSection(html, model.Contracts); + html.AppendLine("
"); + + html.AppendLine("
"); + html.AppendLine("
Recent merchant runs
"); + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine(" "); + + foreach (var run in model.RecentRuns) + { + html.AppendLine(" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine(" "); + } + + if (model.RecentRuns.Count == 0) + { + html.AppendLine(" "); + } + + html.AppendLine(" "); + html.AppendLine("
MerchantScheduledCompletedStatusError
{Encode(run.MerchantName)}
{Encode(run.MerchantId)}
{Encode(run.ScheduledRunUtc.ToString("u"))}{Encode(run.CompletedUtc.ToString("u"))}{RenderBadge(run.Status, RunTone(run.Status))}{Encode(run.ErrorMessage ?? string.Empty)}
No merchant runs have been recorded yet.
"); + html.AppendLine("
"); + + AppendDocumentEnd(html); + return html.ToString(); + } + + public async Task RenderConfigurationHtmlAsync(CancellationToken cancellationToken) + { + var model = await this.BuildDashboardModelAsync(cancellationToken); + var html = new StringBuilder(); + + AppendDocumentStart(html, "Merchant File Processor Configuration", "Appsettings-driven runtime inventory"); + AppendNavigation(html, ("Overview", "/ops"), ("Configuration", "/ops/config"), ("Run history", "/ops/runs"), ("Status board", "/status")); + + html.AppendLine("
"); + html.AppendLine("
"); + html.AppendLine("
Configuration inventory
"); + html.AppendLine("

Appsettings review

"); + html.AppendLine("

This page mirrors the effective settings loaded at startup, so operators can validate what the worker is using without opening the deployment package.

"); + html.AppendLine("
"); + html.AppendLine("
"); + html.AppendLine($"
Authentication
{Encode(model.AuthenticationSummary)}
"); + html.AppendLine($"
File processing
{Encode(model.FileProcessingSummary)}
"); + html.AppendLine($"
Polling
{Encode(model.PollingSummary)}
"); + html.AppendLine("
"); + html.AppendLine("
"); + + html.AppendLine("
"); + AppendSummaryPanel(html, "Runtime summary", new[] + { + ("Environment", model.EnvironmentName), + ("Database", model.ConnectionStringSummary), + ("Logging", model.LoggingSummary), + ("Transaction generation", model.TransactionGenerationSummary), + ("Authentication", model.AuthenticationSummary), + ("File processing", model.FileProcessingSummary), + ("Polling", model.PollingSummary) + }); + + AppendMerchantConfigPanel(html); + html.AppendLine("
"); + + html.AppendLine("
"); + AppendFileProfilesSection(html, model.FileProfiles); + AppendContractsSection(html, model.Contracts); + html.AppendLine("
"); + + html.AppendLine("
"); + html.AppendLine("
Merchant definitions
"); + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine(" "); + + foreach (var merchant in model.Merchants) + { + html.AppendLine(" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine(" "); + } + + if (model.Merchants.Count == 0) + { + html.AppendLine(" "); + } + + html.AppendLine(" "); + html.AppendLine("
MerchantEnabledEstateMerchant IDRun schedule
{Encode(merchant.MerchantName)}{RenderBadge(merchant.Enabled ? "Enabled" : "Disabled", merchant.Enabled ? "good" : "neutral")}{Encode(merchant.EstateId)}{Encode(merchant.MerchantId)}{Encode(merchant.RunTimesUtc)}
No merchants are configured.
"); + html.AppendLine("
"); + + AppendDocumentEnd(html); + return html.ToString(); + } + + public async Task RenderRunsHtmlAsync(CancellationToken cancellationToken) + { + var model = await this.BuildDashboardModelAsync(cancellationToken); + var html = new StringBuilder(); + + AppendDocumentStart(html, "Merchant File Processor Runs", "Recent run history"); + AppendNavigation(html, ("Overview", "/ops"), ("Configuration", "/ops/config"), ("Run history", "/ops/runs"), ("Status board", "/status")); + + html.AppendLine("
"); + html.AppendLine("
"); + html.AppendLine("
Run history
"); + html.AppendLine("

Merchant execution audit trail

"); + html.AppendLine("

Each record shows the scheduled slot, completion time, and final outcome for a merchant processing cycle.

"); + html.AppendLine("
"); + html.AppendLine("
"); + + html.AppendLine("
"); + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine(" "); + + foreach (var run in model.RecentRuns) + { + html.AppendLine(" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine(" "); + } + + if (model.RecentRuns.Count == 0) + { + html.AppendLine(" "); + } + + html.AppendLine(" "); + html.AppendLine("
MerchantScheduledCompletedStatusError
{Encode(run.MerchantName)}
{Encode(run.MerchantId)}
{Encode(run.ScheduledRunUtc.ToString("u"))}{Encode(run.CompletedUtc.ToString("u"))}{RenderBadge(run.Status, RunTone(run.Status))}{Encode(run.ErrorMessage ?? string.Empty)}
No merchant runs are recorded yet.
"); + html.AppendLine("
"); + + AppendDocumentEnd(html); + return html.ToString(); + } + + private async Task BuildDashboardModelAsync(CancellationToken cancellationToken) + { + await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + var options = configurationState.Current; + + var fileSendRows = await dbContext.FileSendRecords + .Select(record => new + { + record.MerchantId, + MerchantName = record.MerchantName ?? record.MerchantId, + record.Status, + record.ProcessedUtc, + record.ProcessingCompleted + }) + .ToListAsync(cancellationToken); + + var merchantRunRows = await dbContext.MerchantRunRecords + .Select(record => new + { + record.MerchantId, + MerchantName = record.MerchantName ?? record.MerchantId, + record.ScheduledRunUtc, + record.CompletedUtc, + record.Status, + record.ErrorMessage + }) + .ToListAsync(cancellationToken); + + var pendingStatusChecks = await dbContext.FileSendRecords.CountAsync(record => + record.Status == FileSendStatuses.Succeeded && + !record.ProcessingCompleted && + !string.IsNullOrWhiteSpace(record.EstateId) && + !string.IsNullOrWhiteSpace(record.FileProcessorFileId), + cancellationToken); + + var recentRuns = merchantRunRows + .OrderByDescending(record => record.CompletedUtc) + .Take(50) + .Select(record => new RunHistoryRow( + record.MerchantId, + record.MerchantName, + record.ScheduledRunUtc, + record.CompletedUtc, + record.Status, + record.ErrorMessage)) + .ToArray(); + + var merchantLookup = fileSendRows + .GroupBy(record => record.MerchantId, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + group => group.Key, + group => new + { + SuccessfulFiles = group.Count(record => record.Status == FileSendStatuses.Succeeded), + FailedFiles = group.Count(record => record.Status == FileSendStatuses.Failed), + LastProcessedUtc = group.Max(record => (DateTimeOffset?)record.ProcessedUtc) + }, + StringComparer.OrdinalIgnoreCase); + + var runLookup = merchantRunRows + .GroupBy(record => record.MerchantId, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + group => group.Key, + group => group.OrderByDescending(record => record.CompletedUtc).First(), + StringComparer.OrdinalIgnoreCase); + + var pendingLookup = await dbContext.FileSendRecords + .Where(record => + record.Status == FileSendStatuses.Succeeded && + !record.ProcessingCompleted && + !string.IsNullOrWhiteSpace(record.MerchantId)) + .GroupBy(record => record.MerchantId) + .Select(group => new + { + MerchantId = group.Key, + PendingCount = group.Count() + }) + .ToListAsync(cancellationToken); + + var pendingLookupMap = pendingLookup.ToDictionary(item => item.MerchantId, item => item.PendingCount, StringComparer.OrdinalIgnoreCase); + + var merchants = options.Merchants + .OrderBy(merchant => merchant.MerchantId, StringComparer.OrdinalIgnoreCase) + .Select(merchant => + { + merchantLookup.TryGetValue(merchant.MerchantId, out var fileSummary); + runLookup.TryGetValue(merchant.MerchantId, out var runSummary); + pendingLookupMap.TryGetValue(merchant.MerchantId, out var pendingCount); + + return new MerchantOperationsRow( + merchant.MerchantId, + string.IsNullOrWhiteSpace(merchant.Name) ? merchant.MerchantId : merchant.Name, + merchant.Enabled, + merchant.EstateId, + string.Join(", ", merchant.GetDailyRunTimesUtc().Select(runTime => runTime.ToString("HH:mm:ss"))), + runSummary?.CompletedUtc, + GetNextScheduledUtc(merchant, DateTimeOffset.UtcNow), + fileSummary?.SuccessfulFiles ?? 0, + fileSummary?.FailedFiles ?? 0, + pendingCount); + }) + .ToArray(); + + var fileProfiles = options.FileProfiles + .OrderBy(profile => profile.FileProfileId, StringComparer.OrdinalIgnoreCase) + .Select(profile => new FileProfileRow( + profile.FileProfileId, + profile.FileProcessorFileProfileId, + profile.Format, + profile.FileExtension, + profile.ContentType, + DescribeLayout(profile), + profile.Fields.Count, + profile.Delimited.HeaderFields.Count, + profile.Delimited.TrailerFields.Count, + DescribeFieldMap(profile.Fields))) + .ToArray(); + + var contracts = options.ContractDefinitions + .OrderBy(contract => contract.ContractId, StringComparer.OrdinalIgnoreCase) + .Select(contract => new ContractMappingRow(contract.ContractId, contract.FileProfileId)) + .ToArray(); + + var metrics = new[] + { + new OperationsMetric("Merchants", options.Merchants.Count.ToString(), $"{options.Merchants.Count(merchant => merchant.Enabled)} enabled"), + new OperationsMetric("Contracts", options.ContractDefinitions.Count.ToString(), "Contract to file-profile mappings"), + new OperationsMetric("File profiles", options.FileProfiles.Count.ToString(), "Delimited and JSON builders"), + new OperationsMetric("Pending checks", pendingStatusChecks.ToString(), "Files awaiting completion polling"), + new OperationsMetric("Run interval", $"{options.FileStatusPolling.PollIntervalSeconds}s", "Status polling cadence"), + new OperationsMetric("Transaction range", $"{options.TransactionGeneration.MinimumTransactionsPerContract}-{options.TransactionGeneration.MaximumTransactionsPerContract}", "Synthetic transaction batch size") + }; + + return new OperationsDashboardModel( + DateTimeOffset.UtcNow, + hostEnvironment.EnvironmentName, + ResolveConnectionStringSummary(), + DescribeAuthentication(), + DescribeFileProcessing(), + DescribeTransactionGeneration(), + DescribePolling(), + DescribeLogging(), + metrics, + merchants, + fileProfiles, + contracts, + recentRuns); + } + + private string ResolveConnectionStringSummary() + { + var connectionString = configuration.GetConnectionString("MerchantFileProcessor"); + if (string.IsNullOrWhiteSpace(connectionString)) + { + return "merchant-file-processor.db"; + } + + const string dataSourcePrefix = "Data Source="; + if (!connectionString.StartsWith(dataSourcePrefix, StringComparison.OrdinalIgnoreCase)) + { + return connectionString; + } + + var path = connectionString[dataSourcePrefix.Length..].Trim(); + return Path.IsPathRooted(path) ? path : Path.Combine(hostEnvironment.ContentRootPath, path); + } + + private string DescribeAuthentication() => + string.IsNullOrWhiteSpace(configurationState.Current.Authentication.ClientId) + ? "Not configured" + : $"Client ID {Mask(configurationState.Current.Authentication.ClientId)} and secret {MaskSecret(configurationState.Current.Authentication.ClientSecret)}"; + + private string DescribeFileProcessing() => + string.IsNullOrWhiteSpace(configurationState.Current.FileProcessing.UserId) + ? "Not configured" + : $"Processing user {Mask(configurationState.Current.FileProcessing.UserId)}"; + + private string DescribeTransactionGeneration() => + $"{configurationState.Current.TransactionGeneration.MinimumTransactionsPerContract} to {configurationState.Current.TransactionGeneration.MaximumTransactionsPerContract} transactions per contract"; + + private string DescribePolling() => + $"{configurationState.Current.FileStatusPolling.PollIntervalSeconds} second poll interval"; + + private string DescribeLogging() => + $"EF Core trace {(frameworkLoggingOptions.EnableEfCoreCommandTrace ? "on" : "off")}, HTTP trace {(frameworkLoggingOptions.EnableHttpClientTrace ? "on" : "off")}"; + + private static string DescribeLayout(FileProfileOptions profile) + { + var parts = new List + { + $"{profile.Format} / {profile.FileExtension}" + }; + + if (profile.Format.Equals(FileProfileFormats.Delimited, StringComparison.OrdinalIgnoreCase)) + { + parts.Add($"delimiter '{profile.Delimited.Delimiter}'"); + parts.Add(profile.Delimited.IncludeHeader ? "header row" : "no header row"); + } + + if (profile.Format.Equals(FileProfileFormats.Json, StringComparison.OrdinalIgnoreCase)) + { + parts.Add(profile.Json.WriteIndented ? "indented JSON" : "compact JSON"); + if (!string.IsNullOrWhiteSpace(profile.Json.RootPropertyName)) + { + parts.Add($"root '{profile.Json.RootPropertyName}'"); + } + } + + return string.Join(", ", parts); + } + + private static string DescribeFieldMap(IEnumerable fields) => + string.Join(", ", fields.Select(field => + { + if (!string.IsNullOrWhiteSpace(field.Value)) + { + return $"{field.Name}={field.Value}"; + } + + var format = string.IsNullOrWhiteSpace(field.Format) ? string.Empty : $" ({field.Format})"; + return $"{field.Name}<-{field.Source}{format}"; + })); + + private static DateTimeOffset? GetNextScheduledUtc(MerchantOptions merchant, DateTimeOffset nowUtc) + { + if (!merchant.Enabled) + { + return null; + } + + var runTimes = merchant.GetDailyRunTimesUtc(); + var nextRunDate = DateOnly.FromDateTime(nowUtc.UtcDateTime); + + foreach (var runTime in runTimes) + { + var nextRun = new DateTimeOffset( + nextRunDate.Year, + nextRunDate.Month, + nextRunDate.Day, + runTime.Hour, + runTime.Minute, + runTime.Second, + TimeSpan.Zero); + + if (nextRun > nowUtc) + { + return nextRun; + } + } + + var firstRunTime = runTimes[0]; + var tomorrow = nextRunDate.AddDays(1); + return new DateTimeOffset( + tomorrow.Year, + tomorrow.Month, + tomorrow.Day, + firstRunTime.Hour, + firstRunTime.Minute, + firstRunTime.Second, + TimeSpan.Zero); + } + + private static void AppendNavigation(StringBuilder html, params (string Label, string Href)[] links) + { + html.AppendLine(" "); + } + + private static void AppendMetrics(StringBuilder html, IReadOnlyList metrics) + { + html.AppendLine("
"); + foreach (var metric in metrics) + { + html.AppendLine("
"); + html.AppendLine($"
{Encode(metric.Label)}
"); + html.AppendLine($"
{Encode(metric.Value)}
"); + html.AppendLine($"
{Encode(metric.Detail)}
"); + html.AppendLine("
"); + } + html.AppendLine("
"); + } + + private static void AppendSummaryPanel(StringBuilder html, string title, IEnumerable<(string Label, string Value)> items) + { + html.AppendLine("
"); + html.AppendLine($"
{Encode(title)}
"); + html.AppendLine("
"); + + foreach (var item in items) + { + html.AppendLine($"
{Encode(item.Label)}
"); + html.AppendLine($"
{Encode(item.Value)}
"); + } + + html.AppendLine("
"); + html.AppendLine("
"); + } + + private static void AppendMerchantConfigPanel(StringBuilder html) + { + html.AppendLine("
"); + html.AppendLine("
Configuration summary
"); + html.AppendLine("

This dashboard reads the effective runtime settings after binding appsettings, environment overrides, and command-line values.

"); + html.AppendLine("
    "); + html.AppendLine("
  • Authentication values are masked and only shown as configured or not configured.
  • "); + html.AppendLine("
  • SQLite is used for the local audit trail and status tables.
  • "); + html.AppendLine("
  • Merchant schedules are interpreted in UTC and backed by the worker loop.
  • "); + html.AppendLine("
"); + html.AppendLine("
"); + } + + private static void AppendFileProfilesSection(StringBuilder html, IReadOnlyList fileProfiles) + { + html.AppendLine("
"); + html.AppendLine("
File profiles
"); + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine(" "); + + foreach (var profile in fileProfiles) + { + html.AppendLine(" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine(" "); + } + + if (fileProfiles.Count == 0) + { + html.AppendLine(" "); + } + + html.AppendLine(" "); + html.AppendLine("
ProfileFormatLayoutFieldsMapping
{Encode(profile.FileProfileId)}
{Encode(profile.FileProcessorFileProfileId)}
{RenderBadge(profile.Format, "neutral")}{Encode(profile.LayoutSummary)}
.{Encode(profile.FileExtension)}{(string.IsNullOrWhiteSpace(profile.ContentType) ? string.Empty : $" / {Encode(profile.ContentType)}")}
{profile.FieldCount} total
{profile.HeaderFieldCount} header, {profile.TrailerFieldCount} trailer
{Encode(profile.FieldMap)}
No file profiles are configured.
"); + html.AppendLine("
"); + } + + private static void AppendContractsSection(StringBuilder html, IReadOnlyList contracts) + { + html.AppendLine("
"); + html.AppendLine("
Contract mappings
"); + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine(" "); + + foreach (var contract in contracts) + { + html.AppendLine(" "); + html.AppendLine($" "); + html.AppendLine($" "); + html.AppendLine(" "); + } + + if (contracts.Count == 0) + { + html.AppendLine(" "); + } + + html.AppendLine(" "); + html.AppendLine("
ContractFile profile
{Encode(contract.ContractId)}{Encode(contract.FileProfileId)}
No contract definitions are configured.
"); + html.AppendLine("
"); + } + + private static void AppendDocumentStart(StringBuilder html, string title, string subtitle) + { + html.AppendLine(""); + html.AppendLine(""); + html.AppendLine(""); + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine(" "); + html.AppendLine($" {WebUtility.HtmlEncode(title)}"); + html.AppendLine(" "); + html.AppendLine(""); + html.AppendLine(""); + html.AppendLine("
"); + html.AppendLine($"
{WebUtility.HtmlEncode(subtitle)}
"); + html.AppendLine($"
{WebUtility.HtmlEncode(title)}
"); + } + + private static void AppendDocumentEnd(StringBuilder html) + { + html.AppendLine("
"); + html.AppendLine(""); + html.AppendLine(""); + } + + private static string RenderBadge(string value, string tone) => + $"{Encode(value)}"; + + private static string RunTone(string status) + { + if (status.Equals(MerchantRunStatuses.Succeeded, StringComparison.OrdinalIgnoreCase)) + { + return "good"; + } + + if (status.Equals(MerchantRunStatuses.Failed, StringComparison.OrdinalIgnoreCase)) + { + return "bad"; + } + + return "neutral"; + } + + private static string Encode(string value) => WebUtility.HtmlEncode(value); + + private static string Mask(string value) + { + var trimmed = value.Trim(); + return trimmed.Length <= 8 ? trimmed : $"{trimmed[..4]}...{trimmed[^4..]}"; + } + + private static string MaskSecret(string value) => + string.IsNullOrWhiteSpace(value) ? "not set" : "configured"; +} diff --git a/TransactionProcessing.MerchantFileProcessor/Reporting/ReportingEndpointRouteBuilderExtensions.cs b/TransactionProcessing.MerchantFileProcessor/Reporting/ReportingEndpointRouteBuilderExtensions.cs index 2b264a7..803f87e 100644 --- a/TransactionProcessing.MerchantFileProcessor/Reporting/ReportingEndpointRouteBuilderExtensions.cs +++ b/TransactionProcessing.MerchantFileProcessor/Reporting/ReportingEndpointRouteBuilderExtensions.cs @@ -1,31 +1,36 @@ +using TransactionProcessing.MerchantFileProcessor.Configuration; + namespace TransactionProcessing.MerchantFileProcessor.Reporting; public static class ReportingEndpointRouteBuilderExtensions { public static IEndpointRouteBuilder MapReportingEndpoints(this IEndpointRouteBuilder endpoints) { - endpoints.MapGet("/", () => Results.Redirect("/status")); + endpoints.MapGet("/", () => Results.Redirect("/ops")); endpoints.MapGet("/api/status", async (IFileStatusReportService reportService, CancellationToken cancellationToken) => Results.Json(await reportService.GetReportAsync(cancellationToken))); - endpoints.MapGet("/status", async (IFileStatusReportService reportService, CancellationToken cancellationToken) => - Results.Content(await reportService.RenderHtmlAsync(cancellationToken), "text/html")); - - endpoints.MapGet("/status/{merchantId}", async (string merchantId, IFileStatusReportService reportService, CancellationToken cancellationToken) => + endpoints.MapGet("/api/configuration", async (IMerchantProcessingConfigurationStore configurationStore, CancellationToken cancellationToken) => { - var html = await reportService.RenderMerchantHtmlAsync(merchantId, cancellationToken); - return html is null - ? Results.NotFound($"Merchant '{merchantId}' was not found.") - : Results.Content(html, "text/html"); + var snapshot = await configurationStore.GetCurrentSnapshotAsync(cancellationToken); + return Results.Content(snapshot.Json, "application/json"); }); - endpoints.MapGet("/status/{merchantId}/files/{fileId:long}", async (string merchantId, long fileId, IFileStatusReportService reportService, CancellationToken cancellationToken) => + endpoints.MapPost("/api/configuration", async (HttpRequest request, IMerchantProcessingConfigurationStore configurationStore, CancellationToken cancellationToken) => { - var html = await reportService.RenderFileHtmlAsync(merchantId, fileId, cancellationToken); - return html is null - ? Results.NotFound($"File '{fileId}' was not found for merchant '{merchantId}'.") - : Results.Content(html, "text/html"); + var form = await request.ReadFormAsync(cancellationToken); + var configurationJson = form["configurationJson"].ToString(); + + try + { + await configurationStore.SaveJsonAsync(configurationJson, cancellationToken); + return Results.Redirect("/ops/config?saved=1"); + } + catch (Exception) + { + return Results.Redirect($"/ops/config?error={Uri.EscapeDataString("Configuration save failed.")}"); + } }); return endpoints; diff --git a/TransactionProcessing.MerchantFileProcessor/Routes.razor b/TransactionProcessing.MerchantFileProcessor/Routes.razor new file mode 100644 index 0000000..172c844 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/Routes.razor @@ -0,0 +1,11 @@ + + + + + + + +

Page not found.

+
+
+
diff --git a/TransactionProcessing.MerchantFileProcessor/TransactionProcessing.MerchantFileProcessor.csproj b/TransactionProcessing.MerchantFileProcessor/TransactionProcessing.MerchantFileProcessor.csproj index 308c58a..52ba432 100644 --- a/TransactionProcessing.MerchantFileProcessor/TransactionProcessing.MerchantFileProcessor.csproj +++ b/TransactionProcessing.MerchantFileProcessor/TransactionProcessing.MerchantFileProcessor.csproj @@ -27,5 +27,8 @@ PreserveNewest + + PreserveNewest + diff --git a/TransactionProcessing.MerchantFileProcessor/Worker.cs b/TransactionProcessing.MerchantFileProcessor/Worker.cs index ec273a6..375861a 100644 --- a/TransactionProcessing.MerchantFileProcessor/Worker.cs +++ b/TransactionProcessing.MerchantFileProcessor/Worker.cs @@ -3,15 +3,16 @@ namespace TransactionProcessing.MerchantFileProcessor; -public sealed class Worker(MerchantProcessingOptions options, +public sealed class Worker(IMerchantProcessingConfigurationState configurationState, IMerchantProcessingService merchantProcessingService, IFileStatusStore fileStatusStore) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { LocalLogger.Context logger = LocalLogger.For(); - logger.LogInformation($"Merchant processor started with {options.Merchants.Count} merchant schedules"); + logger.LogInformation("Merchant processor started"); while (!stoppingToken.IsCancellationRequested) { + MerchantProcessingOptions options = configurationState.Current; MerchantOptions[] enabledMerchants = options.Merchants.Where(merchant => merchant.Enabled).ToArray(); if (enabledMerchants.Length == 0) { diff --git a/TransactionProcessing.MerchantFileProcessor/_Imports.razor b/TransactionProcessing.MerchantFileProcessor/_Imports.razor new file mode 100644 index 0000000..1f007cf --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/_Imports.razor @@ -0,0 +1,9 @@ +@using System.Globalization +@using System.Net +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Rendering +@using TransactionProcessing.MerchantFileProcessor.Configuration +@using TransactionProcessing.MerchantFileProcessor.Reporting +@using TransactionProcessing.MerchantFileProcessor.Services diff --git a/TransactionProcessing.MerchantFileProcessor/appsettings.json b/TransactionProcessing.MerchantFileProcessor/appsettings.json index 72f946e..ba0fb90 100644 --- a/TransactionProcessing.MerchantFileProcessor/appsettings.json +++ b/TransactionProcessing.MerchantFileProcessor/appsettings.json @@ -10,131 +10,8 @@ "EnableHttpClientTrace": false }, "ConnectionStrings": { - "MerchantFileProcessor": "Data Source=merchant-file-processor.db" + "MerchantFileProcessor": "merchant-file-processor.db" }, "ApiConfiguration": { - - }, - "MerchantProcessing": { - "Authentication": { - "ClientId": "", - "ClientSecret": "" - }, - "FileProcessing": { - "UserId": "" - }, - "TransactionGeneration": { - "MinimumTransactionsPerContract": 5, - "MaximumTransactionsPerContract": 25 - }, - "FileStatusPolling": { - "PollIntervalSeconds": 30 - }, - "ContractDefinitions": [ - - ], - "FileProfiles": [ - { - "FileProfileId": "safaricom", - "FileProcessorFileProfileId": "", - "Format": "delimited", - "FileExtension": "csv", - "ContentType": "text/csv", - "Delimited": { - "Delimiter": ",", - "IncludeHeader": false, - "HeaderFields": [ - { - "Name": "RecordType", - "Value": "H" - }, - { - "Name": "ProcessingDate", - "Source": "processingDateUtc", - "Format": "yyyy-MM-dd" - } - ], - "TrailerFields": [ - { - "Name": "RecordType", - "Value": "T" - }, - { - "Name": "RecordCount", - "Source": "recordCount" - } - ] - }, - "Fields": [ - { - "Name": "RecordType", - "Value": "D" - }, - { - "Name": "RecipientMobileNumber", - "Source": "recipientMobileNumber" - }, - { - "Name": "TotalAmount", - "Source": "totalAmount", - "Format": "0.00" - } - ] - }, - { - "FileProfileId": "voucher", - "FileProcessorFileProfileId": "", - "Format": "delimited", - "FileExtension": "csv", - "ContentType": "text/csv", - "Delimited": { - "Delimiter": ",", - "IncludeHeader": false, - "HeaderFields": [ - { - "Name": "RecordType", - "Value": "H" - }, - { - "Name": "ProcessingDate", - "Source": "processingDateUtc", - "Format": "yyyy-MM-dd" - } - ], - "TrailerFields": [ - { - "Name": "RecordType", - "Value": "T" - }, - { - "Name": "RecordCount", - "Source": "recordCount" - } - ] - }, - "Fields": [ - { - "Name": "RecordType", - "Value": "D" - }, - { - "Name": "Issuer", - "Source": "contractIssuer" - }, - { - "Name": "RecipientMobileNumber", - "Source": "recipientMobileNumber" - }, - { - "Name": "TotalAmount", - "Source": "totalAmount", - "Format": "0.00" - } - ] - } - ], - "Merchants": [ - - ] } } diff --git a/TransactionProcessing.MerchantFileProcessor/appsettings.staging.json b/TransactionProcessing.MerchantFileProcessor/appsettings.staging.json index 50c514c..da75673 100644 --- a/TransactionProcessing.MerchantFileProcessor/appsettings.staging.json +++ b/TransactionProcessing.MerchantFileProcessor/appsettings.staging.json @@ -9,143 +9,5 @@ "SecurityService": "https://127.0.0.1:5001", "TransactionProcessorApi": "http://127.0.0.1:5002", "FileProcessorApi": "http://127.0.0.1:5009" - }, - "MerchantProcessing": { - "Authentication": { - "ClientId": "serviceClient", - "ClientSecret": "d192cbc46d834d0da90e8a9d50ded543" - }, - "FileProcessing": { - "UserId": "a51791a8-2731-490c-a8ac-639461f2a41f" - }, - "TransactionGeneration": { - "MinimumTransactionsPerContract": 5, - "MaximumTransactionsPerContract": 25 - }, - "FileStatusPolling": { - "PollIntervalSeconds": 30 - }, - "ContractDefinitions": [ - { - "ContractId": "052A6CA5-47FC-4844-ADCA-7D44EFD8F21C", - "FileProfileId": "safaricom" - }, - { - "ContractId": "9114770B-D188-4F32-9259-B9BD29472370", - "FileProfileId": "voucher" - } - ], - "FileProfiles": [ - { - "FileProfileId": "safaricom", - "FileProcessorFileProfileId": "B2A59ABF-293D-4A6B-B81B-7007503C3476", - "Format": "delimited", - "FileExtension": "csv", - "ContentType": "text/csv", - "Delimited": { - "Delimiter": ",", - "IncludeHeader": false, - "HeaderFields": [ - { - "Name": "RecordType", - "Value": "H" - }, - { - "Name": "ProcessingDate", - "Source": "processingDateUtc", - "Format": "yyyy-MM-dd" - } - ], - "TrailerFields": [ - { - "Name": "RecordType", - "Value": "T" - }, - { - "Name": "RecordCount", - "Source": "recordCount" - } - ] - }, - "Fields": [ - { - "Name": "RecordType", - "Value": "D" - }, - { - "Name": "RecipientMobileNumber", - "Source": "recipientMobileNumber" - }, - { - "Name": "TotalAmount", - "Source": "totalAmount", - "Format": "0.00" - } - ] - }, - { - "FileProfileId": "voucher", - "FileProcessorFileProfileId": "8806EDBC-3ED6-406B-9E5F-A9078356BE99", - "Format": "delimited", - "FileExtension": "csv", - "ContentType": "text/csv", - "Delimited": { - "Delimiter": ",", - "IncludeHeader": false, - "HeaderFields": [ - { - "Name": "RecordType", - "Value": "H" - }, - { - "Name": "ProcessingDate", - "Source": "processingDateUtc", - "Format": "yyyy-MM-dd" - } - ], - "TrailerFields": [ - { - "Name": "RecordType", - "Value": "T" - }, - { - "Name": "RecordCount", - "Source": "recordCount" - } - ] - }, - "Fields": [ - { - "Name": "RecordType", - "Value": "D" - }, - { - "Name": "Issuer", - "Source": "contractIssuer" - }, - { - "Name": "RecipientMobileNumber", - "Source": "recipientMobileNumber" - }, - { - "Name": "TotalAmount", - "Source": "totalAmount", - "Format": "0.00" - } - ] - } - ], - "Merchants": [ - { - "Name": "Test Merchant 1", - "EstateId": "435613ac-a468-47a3-ac4f-649d89764c22", - "MerchantId": "ab1c99fb-1c6c-4694-9a32-b71be5d1da33", - "RunTimesUtc": [ - "09:02:00", - "15:02:00" - ], - "Enabled": true - } - ] } } diff --git a/TransactionProcessing.MerchantFileProcessor/merchant-processing.bootstrap.json b/TransactionProcessing.MerchantFileProcessor/merchant-processing.bootstrap.json new file mode 100644 index 0000000..333f2f7 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/merchant-processing.bootstrap.json @@ -0,0 +1,140 @@ +{ + "MerchantProcessing": { + "Authentication": { + "ClientId": "serviceClient", + "ClientSecret": "d192cbc46d834d0da90e8a9d50ded543" + }, + "FileProcessing": { + "UserId": "a51791a8-2731-490c-a8ac-639461f2a41f" + }, + "TransactionGeneration": { + "MinimumTransactionsPerContract": 5, + "MaximumTransactionsPerContract": 25 + }, + "FileStatusPolling": { + "PollIntervalSeconds": 30 + }, + "ContractDefinitions": [ + { + "ContractId": "052A6CA5-47FC-4844-ADCA-7D44EFD8F21C", + "FileProfileId": "safaricom" + }, + { + "ContractId": "9114770B-D188-4F32-9259-B9BD29472370", + "FileProfileId": "voucher" + } + ], + "FileProfiles": [ + { + "FileProfileId": "safaricom", + "FileProcessorFileProfileId": "B2A59ABF-293D-4A6B-B81B-7007503C3476", + "Format": "delimited", + "FileExtension": "csv", + "ContentType": "text/csv", + "Delimited": { + "Delimiter": ",", + "IncludeHeader": false, + "HeaderFields": [ + { + "Name": "RecordType", + "Value": "H" + }, + { + "Name": "ProcessingDate", + "Source": "processingDateUtc", + "Format": "yyyy-MM-dd" + } + ], + "TrailerFields": [ + { + "Name": "RecordType", + "Value": "T" + }, + { + "Name": "RecordCount", + "Source": "recordCount" + } + ] + }, + "Fields": [ + { + "Name": "RecordType", + "Value": "D" + }, + { + "Name": "RecipientMobileNumber", + "Source": "recipientMobileNumber" + }, + { + "Name": "TotalAmount", + "Source": "totalAmount", + "Format": "0.00" + } + ] + }, + { + "FileProfileId": "voucher", + "FileProcessorFileProfileId": "8806EDBC-3ED6-406B-9E5F-A9078356BE99", + "Format": "delimited", + "FileExtension": "csv", + "ContentType": "text/csv", + "Delimited": { + "Delimiter": ",", + "IncludeHeader": false, + "HeaderFields": [ + { + "Name": "RecordType", + "Value": "H" + }, + { + "Name": "ProcessingDate", + "Source": "processingDateUtc", + "Format": "yyyy-MM-dd" + } + ], + "TrailerFields": [ + { + "Name": "RecordType", + "Value": "T" + }, + { + "Name": "RecordCount", + "Source": "recordCount" + } + ] + }, + "Fields": [ + { + "Name": "RecordType", + "Value": "D" + }, + { + "Name": "Issuer", + "Source": "contractIssuer" + }, + { + "Name": "RecipientMobileNumber", + "Source": "recipientMobileNumber" + }, + { + "Name": "TotalAmount", + "Source": "totalAmount", + "Format": "0.00" + } + ] + } + ], + "Merchants": [ + { + "Name": "Test Merchant 1", + "EstateId": "435613ac-a468-47a3-ac4f-649d89764c22", + "MerchantId": "ab1c99fb-1c6c-4694-9a32-b71be5d1da33", + "RunTimesUtc": [ + "09:02:00", + "15:02:00" + ], + "Enabled": true + } + ] + } +} diff --git a/TransactionProcessing.MerchantFileProcessor/wwwroot/ops.css b/TransactionProcessing.MerchantFileProcessor/wwwroot/ops.css new file mode 100644 index 0000000..237c499 --- /dev/null +++ b/TransactionProcessing.MerchantFileProcessor/wwwroot/ops.css @@ -0,0 +1,648 @@ +:root { + color-scheme: dark; + --bg: #08111f; + --bg-soft: #0d1728; + --panel: rgba(11, 20, 36, 0.92); + --panel-strong: rgba(16, 28, 48, 0.98); + --border: rgba(151, 180, 225, 0.18); + --text: #edf3ff; + --muted: #9db0cc; + --accent: #7cc4ff; + --accent-2: #8ef3c1; + --good: #67de98; + --bad: #ff7b8b; + --warn: #ffd271; +} + +* { + box-sizing: border-box; +} + +html, body { + margin: 0; + min-height: 100%; + background: + radial-gradient(circle at top left, rgba(80, 131, 255, 0.24), transparent 32%), + radial-gradient(circle at top right, rgba(67, 216, 163, 0.14), transparent 26%), + linear-gradient(180deg, #07101e 0%, #08111f 55%, #050b14 100%); + color: var(--text); + font-family: Segoe UI, Arial, sans-serif; +} + +a { + color: var(--accent); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +.app-shell { + display: grid; + grid-template-columns: 290px minmax(0, 1fr); + min-height: 100vh; +} + +.sidebar { + padding: 28px 22px; + border-right: 1px solid var(--border); + background: rgba(4, 8, 15, 0.72); + backdrop-filter: blur(12px); +} + +.brand { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 28px; +} + +.brand-mark { + display: grid; + place-items: center; + width: 52px; + height: 52px; + border-radius: 16px; + background: linear-gradient(135deg, rgba(124, 196, 255, 0.2), rgba(142, 243, 193, 0.15)); + border: 1px solid var(--border); + color: var(--text); + font-weight: 800; + letter-spacing: 0.08em; +} + +.brand-title { + font-size: 1.06rem; + font-weight: 700; +} + +.brand-subtitle { + color: var(--muted); + font-size: 0.85rem; +} + +.sidebar-hero { + margin-top: 4px; +} + +.sidebar-hero-title { + font-size: 1.05rem; + font-weight: 800; + margin-bottom: 8px; +} + +.nav-group-label { + margin: 6px 0 4px; + color: var(--accent-2); + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.72rem; + font-weight: 700; +} + +.sidebar-nav { + display: grid; + gap: 8px; +} + +.sidebar-nav-spaced { + margin-top: 20px; +} + +.nav-link { + display: block; + padding: 12px 14px; + border: 1px solid transparent; + border-radius: 14px; + color: var(--text); + background: rgba(255, 255, 255, 0.02); +} + +.nav-link:hover { + background: rgba(255, 255, 255, 0.05); + text-decoration: none; +} + +.nav-link.active { + background: linear-gradient(135deg, rgba(124, 196, 255, 0.18), rgba(142, 243, 193, 0.09)); + border-color: rgba(124, 196, 255, 0.22); +} + +.sidebar-card { + margin-top: 22px; + padding: 18px; + border: 1px solid var(--border); + border-radius: 18px; + background: linear-gradient(180deg, var(--panel) 0%, var(--panel-strong) 100%); +} + +.sidebar-card-label { + color: var(--accent-2); + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.72rem; + margin-bottom: 10px; +} + +.sidebar-card p { + margin: 0; + color: var(--muted); + line-height: 1.5; +} + +.content { + padding: 26px; +} + +.content-header { + display: flex; + justify-content: space-between; + gap: 20px; + align-items: end; + margin-bottom: 18px; + padding: 18px 20px; + border: 1px solid var(--border); + border-radius: 22px; + background: linear-gradient(180deg, rgba(11, 20, 36, 0.86) 0%, rgba(16, 28, 48, 0.92) 100%); + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.18); + backdrop-filter: blur(10px); +} + +.content-header h1 { + margin: 8px 0 8px; + font-size: clamp(1.8rem, 2.8vw, 2.6rem); + line-height: 1.06; +} + +.content-header p { + margin: 0; + max-width: 72ch; + color: var(--muted); + line-height: 1.5; +} + +.content-header-links { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.quick-link { + display: inline-flex; + align-items: center; + padding: 10px 14px; + border-radius: 999px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.03); + color: var(--text); +} + +.quick-link:hover { + text-decoration: none; + background: rgba(255, 255, 255, 0.06); +} + +.page-shell { + display: grid; + gap: 18px; +} + +.hero-card, +.panel, +.metric-card { + border: 1px solid var(--border); + border-radius: 22px; + background: linear-gradient(180deg, var(--panel) 0%, var(--panel-strong) 100%); + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.22); + backdrop-filter: blur(10px); +} + +.hero-card { + display: grid; + grid-template-columns: minmax(0, 1.8fr) minmax(320px, 0.9fr); + gap: 18px; + padding: 24px; +} + +.hero-copy h1 { + margin: 8px 0 10px; + font-size: clamp(2rem, 3vw, 3.1rem); + line-height: 1.02; +} + +.hero-copy p { + margin: 0; + max-width: 72ch; + color: var(--muted); + font-size: 1.02rem; + line-height: 1.55; +} + +.eyebrow { + color: var(--accent-2); + text-transform: uppercase; + letter-spacing: 0.18em; + font-size: 0.74rem; + font-weight: 700; +} + +.hero-panel { + display: grid; + gap: 12px; + padding: 6px 0 0; +} + +.field-label { + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.72rem; + margin-bottom: 4px; +} + +.field-value { + font-size: 1rem; + line-height: 1.45; +} + +.metric-grid, +.two-col { + display: grid; + gap: 16px; +} + +.metric-grid { + grid-template-columns: repeat(6, minmax(0, 1fr)); +} + +.metric-card { + padding: 18px; + min-height: 114px; +} + +.metric-label { + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.72rem; +} + +.metric-value { + margin: 8px 0 6px; + font-size: 1.9rem; + font-weight: 800; +} + +.metric-detail { + color: var(--muted); + line-height: 1.4; +} + +.panel { + padding: 18px; +} + +.config-item { + display: grid; + gap: 16px; +} + +.hidden-forms { + display: none; +} + +.notice-panel { + color: var(--accent-2); + border-color: rgba(142, 243, 193, 0.24); +} + +.section-header { + display: grid; + gap: 4px; + margin-bottom: 14px; +} + +.section-header h2 { + margin: 0; + font-size: 1.05rem; +} + +.section-header p { + margin: 0; + color: var(--muted); +} + +.two-col { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.master-detail { + display: grid; + grid-template-columns: minmax(360px, 1.1fr) minmax(420px, 0.9fr); + gap: 18px; + align-items: start; +} + +.detail-stack { + display: grid; + gap: 18px; +} + +.table-wrap { + overflow-x: auto; +} + +.data-table { + width: 100%; + border-collapse: collapse; +} + +.data-table th, +.data-table td { + padding: 11px 10px; + text-align: left; + vertical-align: top; + border-top: 1px solid rgba(151, 180, 225, 0.14); +} + +.data-table th { + border-top: none; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.11em; + font-size: 0.74rem; +} + +.data-table tbody tr:hover { + background: rgba(255, 255, 255, 0.03); +} + +.badge { + display: inline-flex; + align-items: center; + padding: 5px 10px; + border-radius: 999px; + border: 1px solid transparent; + font-size: 0.82rem; + font-weight: 700; +} + +.badge.good { + color: var(--good); + background: rgba(103, 222, 152, 0.12); + border-color: rgba(103, 222, 152, 0.24); +} + +.badge.bad { + color: var(--bad); + background: rgba(255, 123, 139, 0.12); + border-color: rgba(255, 123, 139, 0.24); +} + +.badge.neutral { + color: var(--accent); + background: rgba(124, 196, 255, 0.12); + border-color: rgba(124, 196, 255, 0.22); +} + +.badge.warn { + color: var(--warn); + background: rgba(255, 210, 113, 0.12); + border-color: rgba(255, 210, 113, 0.22); +} + +.summary-grid { + display: grid; + grid-template-columns: minmax(180px, 240px) minmax(0, 1fr); + gap: 10px 14px; + margin: 0; +} + +.summary-grid dt { + color: var(--muted); +} + +.summary-grid dd { + margin: 0; +} + +.notes-list { + margin: 0; + padding-left: 20px; + color: var(--muted); + line-height: 1.6; +} + +.notes-list li + li { + margin-top: 8px; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.form-grid label { + display: grid; + gap: 8px; + color: var(--muted); +} + +.form-grid label span { + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.11em; + font-size: 0.72rem; + font-weight: 700; +} + +.span-2 { + grid-column: span 2; +} + +.text-input { + width: 100%; + min-height: 42px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 14px; + background: rgba(0, 0, 0, 0.22); + color: var(--text); + font: inherit; +} + +.text-input:focus { + outline: 2px solid rgba(124, 196, 255, 0.45); + outline-offset: 2px; +} + +.text-input.mono { + font-family: Consolas, Menlo, Monaco, monospace; +} + +.config-form { + display: grid; + gap: 14px; +} + +.field-card { + padding: 14px; + border: 1px solid var(--border); + border-radius: 18px; + background: rgba(255, 255, 255, 0.02); +} + +.field-card + .field-card { + margin-top: 12px; +} + +.config-table td, +.config-table th { + vertical-align: middle; +} + +.config-table .text-input { + min-height: 38px; + padding: 8px 10px; +} + +.selector-table tbody tr { + cursor: pointer; +} + +.selector-table tbody tr.selected { + background: rgba(124, 196, 255, 0.08); +} + +.table-actions { + white-space: nowrap; +} + +.empty-state { + display: grid; + place-items: center; + min-height: 84px; + padding: 16px; + border: 1px dashed rgba(151, 180, 225, 0.22); + border-radius: 16px; + background: rgba(255, 255, 255, 0.02); + color: var(--muted); + text-align: center; + line-height: 1.5; +} + +.mono { + font-family: Consolas, Menlo, Monaco, monospace; +} + +.smallwrap { + white-space: normal; + word-break: break-word; +} + +.muted { + color: var(--muted); +} + +.error-block { + margin: 0; + padding: 14px 16px; + border: 1px solid var(--border); + border-radius: 16px; + background: rgba(0, 0, 0, 0.18); + white-space: pre-wrap; + overflow-wrap: anywhere; + color: var(--text); +} + +.config-editor { + width: 100%; + min-height: 520px; + padding: 16px; + border: 1px solid var(--border); + border-radius: 18px; + background: rgba(0, 0, 0, 0.22); + color: var(--text); + font-family: Consolas, Menlo, Monaco, monospace; + font-size: 0.9rem; + line-height: 1.5; + resize: vertical; +} + +.config-editor:focus { + outline: 2px solid rgba(124, 196, 255, 0.45); + outline-offset: 2px; +} + +.form-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; +} + +.primary-button, +.secondary-button { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 42px; + padding: 0 16px; + border-radius: 999px; + border: 1px solid var(--border); + color: var(--text); + font-weight: 700; +} + +.primary-button { + background: linear-gradient(135deg, rgba(124, 196, 255, 0.22), rgba(142, 243, 193, 0.14)); +} + +.secondary-button { + background: rgba(255, 255, 255, 0.03); +} + +.secondary-button:hover, +.primary-button:hover { + text-decoration: none; + background-color: rgba(255, 255, 255, 0.06); +} + +@media (max-width: 1200px) { + .app-shell { + grid-template-columns: 1fr; + } + + .sidebar { + border-right: none; + border-bottom: 1px solid var(--border); + } + + .metric-grid, + .two-col, + .master-detail, + .hero-card { + grid-template-columns: 1fr; + } + + .content-header { + flex-direction: column; + align-items: start; + } + + .metric-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 760px) { + .content { + padding: 16px; + } + + .metric-grid, + .summary-grid { + grid-template-columns: 1fr; + } +}