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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions TransactionProcessing.MerchantFileProcessor/App.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<HeadOutlet />

<HeadContent>
<link rel="stylesheet" href="/ops.css" />
</HeadContent>

<Routes />
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,26 @@ Task<Result<FileProcessingStatusSnapshot>> 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<Result<Guid>> 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<Guid> { 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(),
Expand All @@ -44,7 +51,8 @@ public async Task<Result<Guid>> Upload(MerchantOptions merchant,

Result<Guid>? result = await fileProcessorClient.UploadFile(accessToken, file.FileName, file.Content, request, cancellationToken);

if (result.IsFailed) {
if (result.IsFailed)
{
return new Result<Guid> { IsSuccess = false, Status = ResultStatus.Failure, Message = $"File processor client failed to upload file '{file.FileName}'." };
}

Expand All @@ -54,10 +62,12 @@ public async Task<Result<Guid>> Upload(MerchantOptions merchant,
public async Task<Result<FileProcessingStatusSnapshot>> GetFileStatus(string accessToken,
Guid estateId,
Guid fileId,
CancellationToken cancellationToken) {
CancellationToken cancellationToken)
{
Result<FileDetails>? 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<FileProcessingStatusSnapshot> { IsSuccess = false, Status = ResultStatus.Failure, Message = $"File processor client failed to retrieve status for file '{fileId}'." };
}

Expand All @@ -69,13 +79,16 @@ public async Task<Result<FileProcessingStatusSnapshot>> 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<FileProcessingLineStatusSnapshot> lines) {
private static bool AreAllLinesResolved(IEnumerable<FileProcessingLineStatusSnapshot> 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;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -53,7 +53,7 @@ public async Task<Result<CachedToken>> GetAccessToken(CancellationToken cancella

private async Task<Result<CachedToken>> RequestToken(CancellationToken cancellationToken)
{
var authentication = options.Authentication;
var authentication = configurationState.Current.Authentication;
var tokenResult = await securityServiceClient.GetToken(
authentication.ClientId,
authentication.ClientSecret,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
@inherits LayoutComponentBase
@inject NavigationManager Navigation

<div class="app-shell">
<aside class="sidebar">
<div class="brand">
<div class="brand-mark">MF</div>
<div>
<div class="brand-title">Merchant File Processor</div>
<div class="brand-subtitle">Operations and status console</div>
</div>
</div>

<div class="sidebar-card sidebar-hero">
<div class="sidebar-card-label">Current area</div>
<div class="sidebar-hero-title">@CurrentAreaTitle</div>
<p>@CurrentAreaSummary</p>
</div>

<nav class="sidebar-nav" aria-label="Operations">
<div class="nav-group-label">Operations</div>
<NavLink class="nav-link" href="/ops" Match="NavLinkMatch.All">Overview</NavLink>
<NavLink class="nav-link" href="/ops/runs">Run history</NavLink>
</nav>

<nav class="sidebar-nav sidebar-nav-spaced" aria-label="Configuration">
<div class="nav-group-label">Configuration</div>
<NavLink class="nav-link" href="/ops/config" Match="NavLinkMatch.All">Hub</NavLink>
<NavLink class="nav-link" href="/ops/config/runtime">Runtime</NavLink>
<NavLink class="nav-link" href="/ops/config/merchants">Merchants</NavLink>
<NavLink class="nav-link" href="/ops/config/file-profiles">File profiles</NavLink>
<NavLink class="nav-link" href="/ops/config/contracts">Contracts</NavLink>
</nav>

<nav class="sidebar-nav sidebar-nav-spaced" aria-label="Status">
<div class="nav-group-label">Status</div>
<NavLink class="nav-link" href="/status" Match="NavLinkMatch.All">Merchant status</NavLink>
</nav>
</aside>

<main class="content">
<header class="content-header">
<div>
<div class="eyebrow">@CurrentAreaTitle</div>
<h1>@CurrentPageTitle</h1>
<p>@CurrentPageSummary</p>
</div>

<div class="content-header-links" aria-label="Quick links">
<NavLink class="quick-link" href="/ops">Ops overview</NavLink>
<NavLink class="quick-link" href="/status">Status board</NavLink>
</div>
</header>

@Body
</main>
</div>

@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);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
@page "/ops/config"
@inject IMerchantProcessingConfigurationStore ConfigurationStore

<PageTitle>Merchant File Processor - Configuration</PageTitle>

@if (!loaded)
{
<div class="page-shell">
<div class="hero-card"><p>Loading configuration data...</p></div>
</div>
}
else
{
<div class="page-shell">
<section class="hero-card">
<div class="hero-copy">
<div class="eyebrow">Configuration hub</div>
<h1>Relational configuration</h1>
<p>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.</p>
</div>
<div class="hero-panel">
<div>
<div class="field-label">Updated</div>
<div class="field-value mono">@snapshot.UpdatedUtc.ToString("u")</div>
</div>
<div>
<div class="field-label">Merchants</div>
<div class="field-value">@snapshot.Options.Merchants.Count</div>
</div>
<div>
<div class="field-label">File profiles</div>
<div class="field-value">@snapshot.Options.FileProfiles.Count</div>
</div>
<div>
<div class="field-label">Contracts</div>
<div class="field-value">@snapshot.Options.ContractDefinitions.Count</div>
</div>
</div>
</section>

<section class="metric-grid">
<article class="metric-card">
<div class="metric-label">Runtime</div>
<div class="metric-value">4</div>
<div class="metric-detail">Authentication, file processing, transaction generation, and polling.</div>
</article>
<article class="metric-card">
<div class="metric-label">Merchants</div>
<div class="metric-value">@snapshot.Options.Merchants.Count</div>
<div class="metric-detail">Schedules, estates, and enabled flags.</div>
</article>
<article class="metric-card">
<div class="metric-label">File profiles</div>
<div class="metric-value">@snapshot.Options.FileProfiles.Count</div>
<div class="metric-detail">Body, header, and trailer field tables.</div>
</article>
<article class="metric-card">
<div class="metric-label">Contracts</div>
<div class="metric-value">@snapshot.Options.ContractDefinitions.Count</div>
<div class="metric-detail">Contract-to-profile mappings.</div>
</article>
<article class="metric-card">
<div class="metric-label">File status</div>
<div class="metric-value">@snapshot.Options.FileStatusPolling.PollIntervalSeconds s</div>
<div class="metric-detail">Polling cadence for completion checks.</div>
</article>
<article class="metric-card">
<div class="metric-label">Transactions</div>
<div class="metric-value">@($"{snapshot.Options.TransactionGeneration.MinimumTransactionsPerContract}-{snapshot.Options.TransactionGeneration.MaximumTransactionsPerContract}")</div>
<div class="metric-detail">Generated transaction range per contract.</div>
</article>
</section>

<section class="two-col">
<article class="panel">
<div class="section-header">
<h2>Runtime settings</h2>
<p>Authentication, file-processing, generation, and polling values.</p>
</div>
<p><a class="primary-button" href="/ops/config/runtime">Open runtime settings</a></p>
</article>

<article class="panel">
<div class="section-header">
<h2>Merchants</h2>
<p>Schedule and estate definitions for each merchant.</p>
</div>
<p><a class="primary-button" href="/ops/config/merchants">Open merchants</a></p>
</article>
</section>

<section class="two-col">
<article class="panel">
<div class="section-header">
<h2>File profiles</h2>
<p>Profiles and their field tables for file generation.</p>
</div>
<p><a class="primary-button" href="/ops/config/file-profiles">Open file profiles</a></p>
</article>

<article class="panel">
<div class="section-header">
<h2>Contract mappings</h2>
<p>Contract IDs mapped to file profiles.</p>
</div>
<p><a class="primary-button" href="/ops/config/contracts">Open contracts</a></p>
</article>
</section>
</div>
}

@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;
}
}
Loading
Loading