From 3579054e7056ba1217052754dcca4832e4697bcb Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Sat, 1 Aug 2026 07:32:19 +0100 Subject: [PATCH] Refactor: Single-page dashboard & persisted config store Major refactor to support a modern Razor Pages dashboard with live merchant stats, editable configuration, and merchant management UI. Introduced a persisted settings store using SQLite, with all configuration (API endpoints, worker settings, connection strings) stored as a single JSON row. Settings are loaded at startup and editable at runtime. Removed old minimal API dashboard and replaced with a dark-themed UI. Updated app settings, documentation, and added a markdown implementation plan. All data is now stored outside the binaries folder for safety. --- .../Pages/Config.cshtml | 50 ++ .../Pages/Config.cshtml.cs | 87 ++++ .../Pages/Index.cshtml | 105 +++++ .../Pages/Index.cshtml.cs | 24 + .../Pages/Merchants.cshtml | 102 ++++ .../Pages/Merchants.cshtml.cs | 176 +++++++ .../Pages/Shared/_Layout.cshtml | 443 ++++++++++++++++++ .../Pages/_ViewImports.cshtml | 4 + .../Pages/_ViewStart.cshtml | 3 + .../MerchantPosSettingsDbContext.cs | 26 + .../Persistence/MerchantPosSettingsStore.cs | 150 ++++++ TransactionProcessing.MerchantPos/Program.cs | 239 ++-------- TransactionProcessing.MerchantPos/README.md | 18 +- .../Runtime/ApiClient.cs | 13 +- .../Runtime/MerchantDashboardModelFactory.cs | 52 ++ .../Runtime/MerchantMetrics.cs | 45 ++ .../Runtime/MerchantPosSettingsSnapshot.cs | 52 ++ .../Runtime/WorkerSettings.cs | 10 +- .../TransactionProcessing.MerchantPos.csproj | 3 + .../Web/MerchantPosViewModels.cs | 22 + .../WorkerHost.cs | 26 +- .../appsettings.json | 36 +- .../appsettings.staging.json | 3 +- .../EstateSetupFunctions.cs | 10 +- .../Program.cs | 2 +- 25 files changed, 1431 insertions(+), 270 deletions(-) create mode 100644 TransactionProcessing.MerchantPos/Pages/Config.cshtml create mode 100644 TransactionProcessing.MerchantPos/Pages/Config.cshtml.cs create mode 100644 TransactionProcessing.MerchantPos/Pages/Index.cshtml create mode 100644 TransactionProcessing.MerchantPos/Pages/Index.cshtml.cs create mode 100644 TransactionProcessing.MerchantPos/Pages/Merchants.cshtml create mode 100644 TransactionProcessing.MerchantPos/Pages/Merchants.cshtml.cs create mode 100644 TransactionProcessing.MerchantPos/Pages/Shared/_Layout.cshtml create mode 100644 TransactionProcessing.MerchantPos/Pages/_ViewImports.cshtml create mode 100644 TransactionProcessing.MerchantPos/Pages/_ViewStart.cshtml create mode 100644 TransactionProcessing.MerchantPos/Persistence/MerchantPosSettingsDbContext.cs create mode 100644 TransactionProcessing.MerchantPos/Persistence/MerchantPosSettingsStore.cs create mode 100644 TransactionProcessing.MerchantPos/Runtime/MerchantDashboardModelFactory.cs create mode 100644 TransactionProcessing.MerchantPos/Runtime/MerchantMetrics.cs create mode 100644 TransactionProcessing.MerchantPos/Runtime/MerchantPosSettingsSnapshot.cs create mode 100644 TransactionProcessing.MerchantPos/Web/MerchantPosViewModels.cs diff --git a/TransactionProcessing.MerchantPos/Pages/Config.cshtml b/TransactionProcessing.MerchantPos/Pages/Config.cshtml new file mode 100644 index 0000000..9a8b186 --- /dev/null +++ b/TransactionProcessing.MerchantPos/Pages/Config.cshtml @@ -0,0 +1,50 @@ +@page +@model TransactionProcessing.MerchantPos.Pages.ConfigModel +@{ + ViewData["Title"] = "Configuration"; +} + +
+
+
+

Configuration

+

Edit app settings and external SQLite paths from a standard form.

+
+
+ + @if (!string.IsNullOrWhiteSpace(Model.Message)) + { +
@Model.Message
+ } + +
+
+
+

Service and API settings

+
+
+
+
+
+
+
+
+
+
+
+ +
+

Storage paths

+
+
+
+
+

Keep both paths outside the binaries folder so the SQLite files remain writable at runtime.

+
+
+ +
+ +
+
+
diff --git a/TransactionProcessing.MerchantPos/Pages/Config.cshtml.cs b/TransactionProcessing.MerchantPos/Pages/Config.cshtml.cs new file mode 100644 index 0000000..2230db5 --- /dev/null +++ b/TransactionProcessing.MerchantPos/Pages/Config.cshtml.cs @@ -0,0 +1,87 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using TransactionProcessing.MerchantPos.Persistence; +using TransactionProcessing.MerchantPos.Runtime; + +namespace TransactionProcessing.MerchantPos.Pages; + +public sealed class ConfigModel : PageModel +{ + private const string SecretMask = "*****"; + private readonly MerchantPosSettingsStore _settingsStore; + + public ConfigModel(MerchantPosSettingsStore settingsStore) + { + _settingsStore = settingsStore; + } + + [BindProperty(SupportsGet = true)] + public string? Message { get; set; } + + [BindProperty] + public ConfigInput Config { get; set; } = new(); + + public void OnGet() + { + ViewData["ActiveNav"] = "config"; + ViewData["Title"] = "Configuration"; + Config = ConfigInput.FromSnapshot(_settingsStore.Current); + Config.ClientSecret = MaskSecret(Config.ClientSecret); + Config.ServiceClientSecret = MaskSecret(Config.ServiceClientSecret); + } + + public async Task OnPostSaveAsync() + { + var settings = _settingsStore.Current; + settings.ApiConfiguration.SecurityService = Config.SecurityService; + settings.ApiConfiguration.TransactionProcessorACL = Config.TransactionProcessorACL; + settings.ApiConfiguration.TransactionProcessorApi = Config.TransactionProcessorApi; + settings.ApiConfiguration.TestHost = Config.TestHost; + + settings.WorkerSettings.ClientId = Config.ClientId; + settings.WorkerSettings.ClientSecret = UnmaskSecret(Config.ClientSecret, settings.WorkerSettings.ClientSecret); + settings.WorkerSettings.ServiceClientId = Config.ServiceClientId; + settings.WorkerSettings.ServiceClientSecret = UnmaskSecret(Config.ServiceClientSecret, settings.WorkerSettings.ServiceClientSecret); + + settings.ConnectionStrings.MerchantDb = MerchantPosSettingsStore.NormalizePath(Config.MerchantDb); + settings.ConnectionStrings.SettingsDb = MerchantPosSettingsStore.NormalizePath(Config.SettingsDb); + + await _settingsStore.SaveAsync(settings); + return RedirectToPage("/Config", new { message = "Configuration saved" }); + } + + private static string MaskSecret(string? value) + => string.IsNullOrWhiteSpace(value) ? string.Empty : SecretMask; + + private static string UnmaskSecret(string? value, string currentValue) + => string.IsNullOrWhiteSpace(value) || string.Equals(value, SecretMask, StringComparison.Ordinal) ? currentValue : value; + + public sealed class ConfigInput + { + public string SecurityService { get; set; } = string.Empty; + public string TransactionProcessorACL { get; set; } = string.Empty; + public string TransactionProcessorApi { get; set; } = string.Empty; + public string TestHost { get; set; } = string.Empty; + public string ClientId { get; set; } = string.Empty; + public string ClientSecret { get; set; } = string.Empty; + public string ServiceClientId { get; set; } = string.Empty; + public string ServiceClientSecret { get; set; } = string.Empty; + public string MerchantDb { get; set; } = string.Empty; + public string SettingsDb { get; set; } = string.Empty; + + public static ConfigInput FromSnapshot(MerchantPosSettingsSnapshot snapshot) + => new() + { + SecurityService = snapshot.ApiConfiguration.SecurityService, + TransactionProcessorACL = snapshot.ApiConfiguration.TransactionProcessorACL, + TransactionProcessorApi = snapshot.ApiConfiguration.TransactionProcessorApi, + TestHost = snapshot.ApiConfiguration.TestHost, + ClientId = snapshot.WorkerSettings.ClientId, + ClientSecret = snapshot.WorkerSettings.ClientSecret, + ServiceClientId = snapshot.WorkerSettings.ServiceClientId, + ServiceClientSecret = snapshot.WorkerSettings.ServiceClientSecret, + MerchantDb = MerchantPosSettingsStore.NormalizePath(snapshot.ConnectionStrings.MerchantDb), + SettingsDb = MerchantPosSettingsStore.NormalizePath(snapshot.ConnectionStrings.SettingsDb) + }; + } +} diff --git a/TransactionProcessing.MerchantPos/Pages/Index.cshtml b/TransactionProcessing.MerchantPos/Pages/Index.cshtml new file mode 100644 index 0000000..5677dd4 --- /dev/null +++ b/TransactionProcessing.MerchantPos/Pages/Index.cshtml @@ -0,0 +1,105 @@ +@page +@model TransactionProcessing.MerchantPos.Pages.IndexModel +@{ + ViewData["Title"] = "Merchant POS Dashboard"; +} +@section Head { + +} + +
+
+
+
+
Merchant POS Control Centre
+

Running stats, live health, and operational context.

+

This page stays focused on live status. Merchant editing and configuration are separate screens so the dashboard stays readable.

+
+ +
+
Last updated: @Model.Dashboard.GeneratedUtc.ToLocalTime().ToString("HH:mm:ss")
+
Settings DB: @Model.Dashboard.SettingsDbPath
+
Mode: Operations console
+
+
+
+ +
+
+
Merchants
+
@Model.Dashboard.Summary.MerchantCount
+
Configured and observed
+
+
+
Balance
+
@Model.Dashboard.Summary.TotalBalance.ToString("0.00")
+
Across all merchants
+
+
+
Sales today
+
@Model.Dashboard.Summary.SalesCount
+
Successful transactions
+
+
+
Failures
+
@Model.Dashboard.Summary.FailedSales
+
Rejected or errored sales
+
+
+ +
+
+
+

Live merchant board

+

Refreshes automatically every five seconds.

+
+ +
+ +
+ + + + + + + + + + + + + @if (Model.Dashboard.Merchants.Count == 0) + { + + } + else + { + foreach (var row in Model.Dashboard.Merchants) + { + + + + + + + + + } + } + +
MerchantBalanceSalesFailuresLast saleLast EOD
No merchants configured yet.
@row.MerchantName@row.Balance.ToString("0.00")@row.Sales@row.FailedSales + + @(row.LastSaleUtc is null ? "Never" : row.LastSaleUtc.Value.ToLocalTime().ToString("dd MMM HH:mm")) + + + + @(row.LastEndOfDayUtc is null ? "Never" : row.LastEndOfDayUtc.Value.ToLocalTime().ToString("dd MMM HH:mm")) + +
+
+
+
diff --git a/TransactionProcessing.MerchantPos/Pages/Index.cshtml.cs b/TransactionProcessing.MerchantPos/Pages/Index.cshtml.cs new file mode 100644 index 0000000..4bbb625 --- /dev/null +++ b/TransactionProcessing.MerchantPos/Pages/Index.cshtml.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Mvc.RazorPages; +using TransactionProcessing.MerchantPos.Runtime; +using TransactionProcessing.MerchantPos.Web; + +namespace TransactionProcessing.MerchantPos.Pages; + +public sealed class IndexModel : PageModel +{ + private readonly MerchantDashboardModelFactory _dashboardFactory; + + public IndexModel(MerchantDashboardModelFactory dashboardFactory) + { + _dashboardFactory = dashboardFactory; + } + + public MerchantDashboardViewModel Dashboard { get; private set; } = default!; + + public async Task OnGetAsync() + { + ViewData["ActiveNav"] = "dashboard"; + ViewData["Title"] = "Merchant POS Dashboard"; + Dashboard = await _dashboardFactory.BuildAsync(); + } +} diff --git a/TransactionProcessing.MerchantPos/Pages/Merchants.cshtml b/TransactionProcessing.MerchantPos/Pages/Merchants.cshtml new file mode 100644 index 0000000..0f0bc13 --- /dev/null +++ b/TransactionProcessing.MerchantPos/Pages/Merchants.cshtml @@ -0,0 +1,102 @@ +@page +@model TransactionProcessing.MerchantPos.Pages.MerchantsModel +@{ + ViewData["Title"] = "Merchant Management"; +} + +
+
+
+

Merchant management

+

Pick one merchant, edit it, and save with a normal form submit.

+
+
+ + @if (!string.IsNullOrWhiteSpace(Model.Message)) + { +
@Model.Message
+ } + +
+ + +
+ @if (Model.Merchants.Count == 0 && !Model.IsDraft) + { +
+
+
Use Add merchant to create the first merchant.
+
+
+ } + else + { +
+ +
+

@Model.Merchant.MerchantName

+
@(Model.IsDraft ? "This is a new merchant draft. Nothing is written to the database until you save it." : "The edit screen stays on one page and saves with a standard form submit.")
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ @if (!Model.IsDraft) + { + + } + +
+
+ } +
+
+
diff --git a/TransactionProcessing.MerchantPos/Pages/Merchants.cshtml.cs b/TransactionProcessing.MerchantPos/Pages/Merchants.cshtml.cs new file mode 100644 index 0000000..9542627 --- /dev/null +++ b/TransactionProcessing.MerchantPos/Pages/Merchants.cshtml.cs @@ -0,0 +1,176 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using TransactionProcessing.MerchantPos.Persistence; +using TransactionProcessing.MerchantPos.Runtime; + +namespace TransactionProcessing.MerchantPos.Pages; + +public sealed class MerchantsModel : PageModel +{ + private const string SecretMask = "*****"; + private readonly MerchantPosSettingsStore _settingsStore; + + public MerchantsModel(MerchantPosSettingsStore settingsStore) + { + _settingsStore = settingsStore; + } + + [BindProperty(SupportsGet = true)] + public int Selected { get; set; } + + [BindProperty(SupportsGet = true)] + public string? Message { get; set; } + + [BindProperty] + public MerchantEditorInput Merchant { get; set; } = new(); + + public IReadOnlyList Merchants => _settingsStore.Current.WorkerSettings.Merchants; + public bool IsDraft => Selected < 0 || Selected >= Merchants.Count; + + public void OnGet() + { + ViewData["ActiveNav"] = "merchants"; + ViewData["Title"] = "Merchant Management"; + Selected = Selected < 0 ? -1 : ClampSelected(Selected); + LoadSelectedMerchant(); + Merchant.Password = MaskSecret(Merchant.Password); + } + + public async Task OnPostAddAsync() + { + return RedirectToPage("/Merchants", new { selected = -1, message = "New merchant ready" }); + } + + public async Task OnPostSaveAsync() + { + var settings = _settingsStore.Current; + Selected = Selected < 0 ? -1 : ClampSelected(Selected); + + if (Selected < 0 || Selected >= settings.WorkerSettings.Merchants.Count) + { + var merchant = CreateNewMerchant(); + ApplyTo(merchant, Merchant); + settings.WorkerSettings.Merchants.Add(merchant); + Selected = settings.WorkerSettings.Merchants.Count - 1; + } + else + { + ApplyTo(settings.WorkerSettings.Merchants[Selected], Merchant); + } + + await _settingsStore.SaveAsync(settings); + return RedirectToPage("/Merchants", new { selected = Selected, message = "Merchant saved" }); + } + + public async Task OnPostRemoveAsync() + { + var settings = _settingsStore.Current; + if (settings.WorkerSettings.Merchants.Count == 0) + { + return RedirectToPage("/Merchants", new { message = "No merchant to remove" }); + } + + Selected = ClampSelected(Selected); + settings.WorkerSettings.Merchants.RemoveAt(Selected); + await _settingsStore.SaveAsync(settings); + + var selected = Math.Clamp(Selected, 0, Math.Max(0, settings.WorkerSettings.Merchants.Count - 1)); + return RedirectToPage("/Merchants", new { selected, message = "Merchant removed" }); + } + + private void LoadSelectedMerchant() + { + if (IsDraft || Merchants.Count == 0) + { + Merchant = MerchantEditorInput.FromMerchant(CreateNewMerchant()); + return; + } + + var merchant = Merchants[Selected]; + Merchant = MerchantEditorInput.FromMerchant(merchant); + } + + private int ClampSelected(int selected) + => Merchants.Count == 0 ? 0 : Math.Clamp(selected, 0, Merchants.Count - 1); + + private static MerchantConfig CreateNewMerchant() + => new() + { + MerchantId = Guid.NewGuid(), + EstateId = Guid.NewGuid(), + MerchantName = "New merchant", + Enabled = false, + ApplicationVersion = "1.0.0", + DeviceIdentifier = $"merchant-{Guid.NewGuid():N}".Substring(0, 16), + Username = string.Empty, + Password = string.Empty, + SaleIntervalSeconds = 30, + FailureInjectionProbability = 0.02, + DepositThreshold = 100, + DepositAmount = 500, + OpeningTime = new TimeOnly(8, 0), + ClosingTime = new TimeOnly(23, 50), + Products = new List() + }; + + private static void ApplyTo(MerchantConfig merchant, MerchantEditorInput input) + { + merchant.MerchantName = input.MerchantName; + merchant.MerchantId = input.MerchantId; + merchant.EstateId = input.EstateId; + merchant.ApplicationVersion = input.ApplicationVersion; + merchant.DeviceIdentifier = input.DeviceIdentifier; + merchant.Username = input.Username; + merchant.Password = UnmaskSecret(input.Password, merchant.Password); + merchant.SaleIntervalSeconds = input.SaleIntervalSeconds; + merchant.FailureInjectionProbability = input.FailureInjectionProbability; + merchant.DepositThreshold = input.DepositThreshold; + merchant.DepositAmount = input.DepositAmount; + merchant.OpeningTime = input.OpeningTime; + merchant.ClosingTime = input.ClosingTime; + merchant.Enabled = input.Enabled; + } + + private static string MaskSecret(string? value) + => string.IsNullOrWhiteSpace(value) ? string.Empty : SecretMask; + + private static string UnmaskSecret(string? value, string currentValue) + => string.IsNullOrWhiteSpace(value) || string.Equals(value, SecretMask, StringComparison.Ordinal) ? currentValue : value; + + public sealed class MerchantEditorInput + { + public string MerchantName { get; set; } = string.Empty; + public Guid MerchantId { get; set; } + public Guid EstateId { get; set; } + public string ApplicationVersion { get; set; } = string.Empty; + public string DeviceIdentifier { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public int SaleIntervalSeconds { get; set; } + public double FailureInjectionProbability { get; set; } + public decimal DepositThreshold { get; set; } + public decimal DepositAmount { get; set; } + public TimeOnly OpeningTime { get; set; } + public TimeOnly ClosingTime { get; set; } + public bool Enabled { get; set; } + + public static MerchantEditorInput FromMerchant(MerchantConfig merchant) + => new() + { + MerchantName = merchant.MerchantName, + MerchantId = merchant.MerchantId, + EstateId = merchant.EstateId, + ApplicationVersion = merchant.ApplicationVersion, + DeviceIdentifier = merchant.DeviceIdentifier, + Username = merchant.Username, + Password = merchant.Password, + SaleIntervalSeconds = merchant.SaleIntervalSeconds, + FailureInjectionProbability = merchant.FailureInjectionProbability, + DepositThreshold = merchant.DepositThreshold, + DepositAmount = merchant.DepositAmount, + OpeningTime = merchant.OpeningTime, + ClosingTime = merchant.ClosingTime, + Enabled = merchant.Enabled + }; + } +} diff --git a/TransactionProcessing.MerchantPos/Pages/Shared/_Layout.cshtml b/TransactionProcessing.MerchantPos/Pages/Shared/_Layout.cshtml new file mode 100644 index 0000000..108f39e --- /dev/null +++ b/TransactionProcessing.MerchantPos/Pages/Shared/_Layout.cshtml @@ -0,0 +1,443 @@ +@{ + var activeNav = (ViewData["ActiveNav"] as string) ?? string.Empty; + string NavClass(string key) => string.Equals(activeNav, key, StringComparison.OrdinalIgnoreCase) ? "nav-link active" : "nav-link"; + var title = (ViewData["Title"] as string) ?? "Merchant POS"; + var dashboardNav = NavClass("dashboard"); + var merchantsNav = NavClass("merchants"); + var configNav = NavClass("config"); +} + + + + + + @title + @await RenderSectionAsync("Head", required: false) + + + +
+ + @RenderBody() +
+ + diff --git a/TransactionProcessing.MerchantPos/Pages/_ViewImports.cshtml b/TransactionProcessing.MerchantPos/Pages/_ViewImports.cshtml new file mode 100644 index 0000000..d274dac --- /dev/null +++ b/TransactionProcessing.MerchantPos/Pages/_ViewImports.cshtml @@ -0,0 +1,4 @@ +@using TransactionProcessing.MerchantPos +@using TransactionProcessing.MerchantPos.Runtime +@using TransactionProcessing.MerchantPos.Web +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/TransactionProcessing.MerchantPos/Pages/_ViewStart.cshtml b/TransactionProcessing.MerchantPos/Pages/_ViewStart.cshtml new file mode 100644 index 0000000..820a2f6 --- /dev/null +++ b/TransactionProcessing.MerchantPos/Pages/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "_Layout"; +} diff --git a/TransactionProcessing.MerchantPos/Persistence/MerchantPosSettingsDbContext.cs b/TransactionProcessing.MerchantPos/Persistence/MerchantPosSettingsDbContext.cs new file mode 100644 index 0000000..886d139 --- /dev/null +++ b/TransactionProcessing.MerchantPos/Persistence/MerchantPosSettingsDbContext.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore; + +namespace TransactionProcessing.MerchantPos.Persistence; + +public sealed class MerchantPosSettingsDbContext : DbContext +{ + public MerchantPosSettingsDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet Settings => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasKey(x => x.Id); + modelBuilder.Entity().Property(x => x.Json).IsRequired(); + modelBuilder.Entity().Property(x => x.UpdatedUtc).IsRequired(); + } +} + +public sealed class MerchantPosSettingsRecord +{ + public int Id { get; set; } + public string Json { get; set; } = string.Empty; + public DateTime UpdatedUtc { get; set; } +} diff --git a/TransactionProcessing.MerchantPos/Persistence/MerchantPosSettingsStore.cs b/TransactionProcessing.MerchantPos/Persistence/MerchantPosSettingsStore.cs new file mode 100644 index 0000000..bf2d4fd --- /dev/null +++ b/TransactionProcessing.MerchantPos/Persistence/MerchantPosSettingsStore.cs @@ -0,0 +1,150 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using TransactionProcessing.MerchantPos.Runtime; + +namespace TransactionProcessing.MerchantPos.Persistence; + +public sealed class MerchantPosSettingsStore +{ + private const int SingletonRowId = 1; + private readonly IConfiguration _configuration; + private readonly JsonSerializerOptions _serializerOptions; + private readonly ILogger _logger; + private MerchantPosSettingsSnapshot _current = new(); + + public MerchantPosSettingsStore( + IConfiguration configuration, + JsonSerializerOptions serializerOptions, + ILogger logger) + { + _configuration = configuration; + _serializerOptions = serializerOptions; + _logger = logger; + } + + public MerchantPosSettingsSnapshot Current => _current; + + public async Task InitialiseAsync(CancellationToken cancellationToken = default) + { + var defaults = MerchantPosSettingsSnapshot.FromConfiguration(_configuration); + defaults.EnsureDefaults(); + + await using var db = CreateContext(defaults.ConnectionStrings.SettingsDb); + await db.Database.EnsureCreatedAsync(cancellationToken); + + var record = await db.Settings.SingleOrDefaultAsync(x => x.Id == SingletonRowId, cancellationToken); + if (record is null || string.IsNullOrWhiteSpace(record.Json)) + { + _current = defaults; + await SaveAsync(defaults, cancellationToken); + return; + } + + var loaded = JsonSerializer.Deserialize(record.Json, _serializerOptions); + _current = loaded ?? defaults; + _current = MergeWithDefaults(_current, defaults); + _current.ConnectionStrings.MerchantDb = NormalizePath(_current.ConnectionStrings.MerchantDb); + _current.ConnectionStrings.SettingsDb = NormalizePath(_current.ConnectionStrings.SettingsDb); + _logger.LogInformation("Loaded merchant POS settings from {Path}", ResolvePath(_current.ConnectionStrings.SettingsDb)); + } + + public async Task SaveAsync(MerchantPosSettingsSnapshot snapshot, CancellationToken cancellationToken = default) + { + snapshot.EnsureDefaults(); + snapshot.ConnectionStrings.MerchantDb = NormalizePath(snapshot.ConnectionStrings.MerchantDb); + snapshot.ConnectionStrings.SettingsDb = NormalizePath(snapshot.ConnectionStrings.SettingsDb); + _current = snapshot; + + var settingsDbPath = ResolvePath(snapshot.ConnectionStrings.SettingsDb); + await using var db = CreateContext(settingsDbPath); + await db.Database.EnsureCreatedAsync(cancellationToken); + + var json = JsonSerializer.Serialize(snapshot, _serializerOptions); + var record = await db.Settings.SingleOrDefaultAsync(x => x.Id == SingletonRowId, cancellationToken); + if (record is null) + { + record = new MerchantPosSettingsRecord { Id = SingletonRowId }; + db.Settings.Add(record); + } + + record.Json = json; + record.UpdatedUtc = DateTime.UtcNow; + await db.SaveChangesAsync(cancellationToken); + _logger.LogInformation("Saved merchant POS settings to {Path}", settingsDbPath); + } + + public string ResolvePath(string path) + { + path = NormalizePath(path); + if (string.IsNullOrWhiteSpace(path)) + { + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "TransactionProcessing", "MerchantPos", "merchant-pos-settings.db"); + } + + if (Path.IsPathRooted(path)) + { + return path; + } + + return Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "TransactionProcessing", "MerchantPos", path)); + } + + public static string BuildSqliteConnectionString(string path) + => string.IsNullOrWhiteSpace(path) ? string.Empty : $"Data Source={NormalizePath(path)}"; + + public static string NormalizePath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return string.Empty; + } + + const string prefix = "Data Source="; + return path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + ? path[prefix.Length..].Trim() + : path.Trim(); + } + + private static MerchantPosSettingsSnapshot MergeWithDefaults(MerchantPosSettingsSnapshot snapshot, MerchantPosSettingsSnapshot defaults) + { + snapshot.WorkerSettings ??= new WorkerSettings(); + snapshot.WorkerSettings.ClientId = string.IsNullOrWhiteSpace(snapshot.WorkerSettings.ClientId) ? defaults.WorkerSettings.ClientId : snapshot.WorkerSettings.ClientId; + snapshot.WorkerSettings.ClientSecret = string.IsNullOrWhiteSpace(snapshot.WorkerSettings.ClientSecret) ? defaults.WorkerSettings.ClientSecret : snapshot.WorkerSettings.ClientSecret; + snapshot.WorkerSettings.ServiceClientId = string.IsNullOrWhiteSpace(snapshot.WorkerSettings.ServiceClientId) ? defaults.WorkerSettings.ServiceClientId : snapshot.WorkerSettings.ServiceClientId; + snapshot.WorkerSettings.ServiceClientSecret = string.IsNullOrWhiteSpace(snapshot.WorkerSettings.ServiceClientSecret) ? defaults.WorkerSettings.ServiceClientSecret : snapshot.WorkerSettings.ServiceClientSecret; + if (snapshot.WorkerSettings.Merchants is null || snapshot.WorkerSettings.Merchants.Count == 0) + { + snapshot.WorkerSettings.Merchants = defaults.WorkerSettings.Merchants; + } + + snapshot.ApiConfiguration ??= new ApiConfigurationSettings(); + snapshot.ApiConfiguration.SecurityService = string.IsNullOrWhiteSpace(snapshot.ApiConfiguration.SecurityService) ? defaults.ApiConfiguration.SecurityService : snapshot.ApiConfiguration.SecurityService; + snapshot.ApiConfiguration.TransactionProcessorACL = string.IsNullOrWhiteSpace(snapshot.ApiConfiguration.TransactionProcessorACL) ? defaults.ApiConfiguration.TransactionProcessorACL : snapshot.ApiConfiguration.TransactionProcessorACL; + snapshot.ApiConfiguration.TransactionProcessorApi = string.IsNullOrWhiteSpace(snapshot.ApiConfiguration.TransactionProcessorApi) ? defaults.ApiConfiguration.TransactionProcessorApi : snapshot.ApiConfiguration.TransactionProcessorApi; + snapshot.ApiConfiguration.TestHost = string.IsNullOrWhiteSpace(snapshot.ApiConfiguration.TestHost) ? defaults.ApiConfiguration.TestHost : snapshot.ApiConfiguration.TestHost; + + snapshot.ConnectionStrings ??= new ConnectionStringsSettings(); + snapshot.ConnectionStrings.MerchantDb = string.IsNullOrWhiteSpace(snapshot.ConnectionStrings.MerchantDb) ? defaults.ConnectionStrings.MerchantDb : snapshot.ConnectionStrings.MerchantDb; + snapshot.ConnectionStrings.SettingsDb = string.IsNullOrWhiteSpace(snapshot.ConnectionStrings.SettingsDb) ? defaults.ConnectionStrings.SettingsDb : snapshot.ConnectionStrings.SettingsDb; + snapshot.EnsureDefaults(); + return snapshot; + } + + private MerchantPosSettingsDbContext CreateContext(string settingsDbPath) + { + var fullPath = ResolvePath(settingsDbPath); + var directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + var options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={fullPath}") + .Options; + + return new MerchantPosSettingsDbContext(options); + } +} diff --git a/TransactionProcessing.MerchantPos/Program.cs b/TransactionProcessing.MerchantPos/Program.cs index 237a00d..ebc3bc0 100644 --- a/TransactionProcessing.MerchantPos/Program.cs +++ b/TransactionProcessing.MerchantPos/Program.cs @@ -1,36 +1,26 @@ using ClientProxyBase; -using MerchantPos.EF.Models; using MerchantPos.EF.Persistence; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Hosting; using NLog; using NLog.Extensions.Logging; using NLog.Web; using SecurityService.Client; using Shared.Serialisation; -using System; -using System.Collections.Concurrent; -using System.IO; using System.Text.Json; +using TransactionProcessing.MerchantPos.Persistence; using TransactionProcessing.MerchantPos.Runtime; using TransactionProcessor.Client; -using ILogger = Microsoft.Extensions.Logging.ILogger; var logger = LogManager.Setup().LoadConfigurationFromFile("nlog.config").GetCurrentClassLogger(); + try { - // Use Info here so default config (Info+) writes this during startup logger.Info("Starting application initialization"); - // Explicitly construct the builder with environment support and then load environment-specific appsettings var envName = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? Environments.Production; - // IMPORTANT: use the application's folder (not process working dir) as the ContentRootPath. - // When running as a service the current directory is often C:\Windows\System32 which causes the - // "appsettings.json not found" error. var contentRoot = AppContext.BaseDirectory; var builder = WebApplication.CreateBuilder(new WebApplicationOptions @@ -40,11 +30,7 @@ EnvironmentName = envName }); - // Load hosting.json so values such as "urls" or Kestrel endpoints are applied builder.Configuration.AddJsonFile("hosting.json", optional: true, reloadOnChange: true); - - // Explicit configuration ordering: appsettings.json, appsettings.{Environment}.json, environment vars, command line - // Make appsettings.json optional to avoid hard crash when missing; log a warning instead. builder.Configuration .SetBasePath(builder.Environment.ContentRootPath) .AddJsonFile("appsettings.json", optional: true, reloadOnChange: true) @@ -52,93 +38,51 @@ .AddEnvironmentVariables() .AddCommandLine(args); - // Use NLog as the logging provider for the host builder.Host.UseNLog(); - - // Enable running as a Windows Service (call before Build) builder.Host.UseWindowsService(); - // Configure logging (providers) - NLog will be used via UseNLog - builder.Logging.ClearProviders(); // Remove default providers + builder.Logging.ClearProviders(); builder.Logging.AddConfiguration(builder.Configuration.GetSection("Logging")); - // Keep Console/Debug providers if you still want them alongside NLog (optional) builder.Logging.AddConsole(); builder.Logging.AddDebug(); builder.Logging.AddNLog(); - // Worker Services builder.Services.AddHostedService(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton>(_ => obj => StringSerialiser.Serialise(obj)); + builder.Services.AddSingleton>(_ => (str, type) => StringSerialiser.DeserializeObject(str, type)); + builder.Services.AddSingleton(SystemTextJsonSerializer.GetDefaultJsonSerializerOptions()); + builder.Services.AddScoped(); + builder.Services.AddRazorPages(); - var connectionString = builder.Configuration.GetConnectionString("MerchantDb"); - - // EF Core - builder.Services.AddDbContext(options => - { - options.UseSqlite(connectionString); - }); - - // Repos + services - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddHttpContextAccessor(); builder.Services.RegisterHttpClient(); builder.Services.RegisterHttpClient(); builder.Services.AddHttpClient(); - + builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); - - builder.Services.AddSingleton(); - builder.Services.AddSingleton>(_ => obj => StringSerialiser.Serialise(obj)); - builder.Services.AddSingleton>(_ => (str, type) => StringSerialiser.DeserializeObject(str, type)); - - var serialiserSettings = SystemTextJsonSerializer.GetDefaultJsonSerializerOptions(); - builder.Services.AddSingleton(serialiserSettings); - - // Replace the existing AddSingleton>(...) registration with this: - builder.Services.AddSingleton>(sp => + builder.Services.AddDbContext((sp, options) => { - // Resolve IConfiguration from the DI container - var config = sp.GetRequiredService().GetSection("ApiConfiguration"); - - // Return a small resolver that looks up keys in the ApiConfiguration section (case-insensitive) - return (string configSetting) => - { - if (string.IsNullOrWhiteSpace(configSetting)) - return string.Empty; - - // Section indexer is case-sensitive by default, so use GetChildren() to perform case-insensitive lookup - var child = config.GetChildren() - .FirstOrDefault(c => string.Equals(c.Key, configSetting, StringComparison.OrdinalIgnoreCase)); - return child?.Value ?? string.Empty; - }; + var settings = sp.GetRequiredService().Current; + var connectionString = MerchantPosSettingsStore.BuildSqliteConnectionString(settings.ConnectionStrings.MerchantDb); + options.UseSqlite(connectionString); }); - // Health checks + builder.Services.AddHealthChecks(); - - // Bind config - var settings = new WorkerSettings(); - builder.Configuration.GetSection("WorkerSettings").Bind(settings); - builder.Services.AddSingleton(settings); - // --- Build the web app (this replaces ConfigureWebHostDefaults) --- var app = builder.Build(); - // If appsettings.json was missing, log a warning so the deployment issue is visible - var cfgFile = Path.Combine(builder.Environment.ContentRootPath, "appsettings.json"); - if (!File.Exists(cfgFile)) - { - var warnLogger = app.Services.GetRequiredService>(); - warnLogger.LogWarning("appsettings.json was not found at {Path}. Using defaults and environment variables.", cfgFile); - } - - // Auto-create SQLite database and tables using (var scope = app.Services.CreateScope()) { + var settingsStore = scope.ServiceProvider.GetRequiredService(); + await settingsStore.InitialiseAsync(); + var db = scope.ServiceProvider.GetRequiredService(); - db.Database.EnsureCreated(); + await db.Database.EnsureCreatedAsync(); var diLogger = scope.ServiceProvider.GetRequiredService>(); Shared.Logger.Logger.Initialise(diLogger); @@ -147,152 +91,23 @@ StringSerialiser.Initialise(serialiser); } - app.MapGet("/metrics/stream", async (HttpContext ctx, IEfRepository repo, MerchantMetrics metrics) => - { - ctx.Response.Headers.Add("Content-Type", "text/event-stream"); - ctx.Response.Headers.Add("Cache-Control", "no-cache"); + app.MapGet("/api/settings", (MerchantPosSettingsStore settingsStore) => + Results.Json(settingsStore.Current)); - while (!ctx.RequestAborted.IsCancellationRequested) - { - var balances = await repo.GetAllMerchants(); + app.MapGet("/api/dashboard", async (MerchantDashboardModelFactory factory) => + Results.Json(await factory.BuildAsync())); - var dto = balances.Select(b => new - { - MerchantId = b.MerchantId, - MerchantName = b.MerchantName, - Balance = metrics.Get(b.MerchantId).Balance, - Sales = metrics.Get(b.MerchantId).SalesCount, - FailedSales = metrics.Get(b.MerchantId).FailedSales, - LastSale = metrics.Get(b.MerchantId).LastSaleUtc, - LastEndOfDay = metrics.Get(b.MerchantId).LastEndOfDay - }).ToList(); - - string json = JsonSerializer.Serialize(dto); - await ctx.Response.WriteAsync($"data: {json}\n\n"); - await ctx.Response.Body.FlushAsync(); - - await Task.Delay(1000); // update every second - } - }); - - app.MapGet("/dashboard", () => - { - var html = @" - - - - Merchant Dashboard - - - -

Real-Time Merchant Metrics

- - - - - - - - - - - - - -
MerchantBalanceSales CountFailed SalesLast Sale (UTC)Last EOD (UTC)
- - - -"; - - return Results.Text(html, "text/html"); - }); - - // Health Check Endpoint + app.MapRazorPages(); app.MapHealthChecks("/health"); - // Run both web app + background workers await app.RunAsync(); } catch (Exception ex) { - // NLog: catch setup errors logger.Error(ex, "Application stopped because of exception"); throw; } finally { - // Ensure to flush and stop internal timers/threads before application-exit (important for NLog) LogManager.Shutdown(); } - - -public record BalanceDto(Guid MerchantId, decimal Balance); - - -public class MerchantMetrics -{ - private readonly ConcurrentDictionary _metrics - = new(); - - public MerchantMetricSnapshot Get(Guid merchantId) - => _metrics.GetOrAdd(merchantId, new MerchantMetricSnapshot()); - - public void IncrementSales(Guid merchantId) - { - var m = Get(merchantId); - Interlocked.Increment(ref m.SalesCount); - m.LastSaleUtc = DateTime.UtcNow; - } - - public void IncrementFailedSales(Guid merchantId) - { - var m = Get(merchantId); - Interlocked.Increment(ref m.FailedSales); - } - - public void SetBalance(Guid merchantId, Decimal balance) - { - var m = Get(merchantId); - m.Balance = balance; - } - - public void SetLastEndOfDay(Guid merchantId) - { - var m = Get(merchantId); - m.LastEndOfDay = DateTime.UtcNow; - } -} - -public class MerchantMetricSnapshot -{ - public int SalesCount; - public int FailedSales; - public decimal Balance; - public DateTime? LastSaleUtc; - public DateTime? LastEndOfDay; -} \ No newline at end of file diff --git a/TransactionProcessing.MerchantPos/README.md b/TransactionProcessing.MerchantPos/README.md index 6455102..7fede6e 100644 --- a/TransactionProcessing.MerchantPos/README.md +++ b/TransactionProcessing.MerchantPos/README.md @@ -1,17 +1,19 @@ -# Merchant POS with EF Core and Health Dashboard +# Merchant POS with Dashboard and Persisted Configuration -This skeleton demonstrates: -- EF Core (SQLite) persistent store (`merchantpos.db`) -- Minimal health endpoint at `/health` -- Dashboard endpoint at `/dashboard` (returns balances JSON) +This app now provides: +- A single-page operations console at `/` +- Live merchant stats from `/api/dashboard` +- Editable configuration stored in SQLite outside the binaries folder +- Health endpoint at `/health` - DbContext & repository abstraction `IEfRepository` / `EfRepository` How to run: - `dotnet restore` - `dotnet build` - `dotnet run` -- Visit `http://localhost:5000/health` and `/dashboard` +- Visit `http://localhost:9600/` for the console Notes: -- This is a skeleton. The worker loop is a placeholder — replace with your merchant runtime logic. -- Database file will be created next to the application: `merchantpos.db`. +- `ConnectionStrings:SettingsDb` and `ConnectionStrings:MerchantDb` store plain file paths. +- The code behind adds the SQLite `Data Source=` prefix when opening each database. +- Both paths should point outside the binaries folder, for example under `G:\Git\TransactionProcessing\SupportTools\MerchantPosData\`. diff --git a/TransactionProcessing.MerchantPos/Runtime/ApiClient.cs b/TransactionProcessing.MerchantPos/Runtime/ApiClient.cs index 709e8f2..7015635 100644 --- a/TransactionProcessing.MerchantPos/Runtime/ApiClient.cs +++ b/TransactionProcessing.MerchantPos/Runtime/ApiClient.cs @@ -14,6 +14,7 @@ using TransactionProcessorACL.DataTransferObjects; using TransactionProcessorACL.DataTransferObjects.Responses; using OperatorTotalRequest = TransactionProcessorACL.DataTransferObjects.OperatorTotalRequest; +using TransactionProcessing.MerchantPos.Persistence; namespace TransactionProcessing.MerchantPos.Runtime; @@ -36,17 +37,17 @@ Task> SendSale(MerchantConfig cfg, TokenResponse token, Pro public class ApiClient : ClientProxyBase.ClientProxyBase, IApiClient { private readonly ISecurityServiceClient SecurityClient; private readonly ITransactionProcessorClient TransactionProcessorClient; - private readonly Func BaseAddressResolver; + private readonly MerchantPosSettingsStore SettingsStore; public ApiClient(ISecurityServiceClient securityClient, ITransactionProcessorClient transactionProcessorClient, HttpClient httpClient, - Func baseAddressResolver, + MerchantPosSettingsStore settingsStore, Func serialise, Func deserialise) : base(httpClient, serialise, deserialise) { this.SecurityClient = securityClient; this.TransactionProcessorClient = transactionProcessorClient; - this.BaseAddressResolver = baseAddressResolver; + this.SettingsStore = settingsStore; } public async Task> GetToken(String clientId, @@ -389,7 +390,7 @@ private async Task> SendTransactionRequest> CreateBillPaymentBill(Decimal billAmount, CancellationToken cancellationToken) { Int32 accountNumber = this._rng.Next(1, 100000); - String baseAddress = this.BaseAddressResolver("TestHost"); + String baseAddress = this.SettingsStore.Current.ApiConfiguration.TestHost; var body = new { @@ -423,7 +424,7 @@ private String BuildRequestUrl(String route) { private async Task> CreateBillPaymentMeter(CancellationToken cancellationToken) { Int32 meterNumber = this._rng.Next(1, 100000); - String baseAddress = this.BaseAddressResolver("TestHost"); + String baseAddress = this.SettingsStore.Current.ApiConfiguration.TestHost; var body = new { due_date = DateTime.Now.AddDays(1), diff --git a/TransactionProcessing.MerchantPos/Runtime/MerchantDashboardModelFactory.cs b/TransactionProcessing.MerchantPos/Runtime/MerchantDashboardModelFactory.cs new file mode 100644 index 0000000..8a68489 --- /dev/null +++ b/TransactionProcessing.MerchantPos/Runtime/MerchantDashboardModelFactory.cs @@ -0,0 +1,52 @@ +using MerchantPos.EF.Persistence; +using TransactionProcessing.MerchantPos.Persistence; +using TransactionProcessing.MerchantPos.Web; + +namespace TransactionProcessing.MerchantPos.Runtime; + +public sealed class MerchantDashboardModelFactory +{ + private readonly IEfRepository _repo; + private readonly MerchantMetrics _metrics; + private readonly MerchantPosSettingsStore _settingsStore; + + public MerchantDashboardModelFactory( + IEfRepository repo, + MerchantMetrics metrics, + MerchantPosSettingsStore settingsStore) + { + _repo = repo; + _metrics = metrics; + _settingsStore = settingsStore; + } + + public async Task BuildAsync() + { + var merchantRows = await _repo.GetAllMerchants(); + var rows = merchantRows.Select(row => + { + var snapshot = _metrics.Get(row.MerchantId); + return new MerchantDashboardRow( + row.MerchantId, + row.MerchantName, + snapshot.Balance, + snapshot.SalesCount, + snapshot.FailedSales, + snapshot.LastSaleUtc, + snapshot.LastEndOfDay); + }).ToList(); + + var settings = _settingsStore.Current; + var summary = new MerchantDashboardSummary( + Math.Max(settings.WorkerSettings.Merchants.Count, rows.Count), + rows.Sum(x => x.Balance), + rows.Sum(x => x.Sales), + rows.Sum(x => x.FailedSales)); + + return new MerchantDashboardViewModel( + DateTime.UtcNow, + _settingsStore.ResolvePath(settings.ConnectionStrings.SettingsDb), + summary, + rows); + } +} diff --git a/TransactionProcessing.MerchantPos/Runtime/MerchantMetrics.cs b/TransactionProcessing.MerchantPos/Runtime/MerchantMetrics.cs new file mode 100644 index 0000000..3344048 --- /dev/null +++ b/TransactionProcessing.MerchantPos/Runtime/MerchantMetrics.cs @@ -0,0 +1,45 @@ +using System.Collections.Concurrent; + +namespace TransactionProcessing.MerchantPos.Runtime; + +public sealed class MerchantMetrics +{ + private readonly ConcurrentDictionary _metrics = new(); + + public MerchantMetricSnapshot Get(Guid merchantId) + => _metrics.GetOrAdd(merchantId, _ => new MerchantMetricSnapshot()); + + public void IncrementSales(Guid merchantId) + { + var metric = Get(merchantId); + Interlocked.Increment(ref metric.SalesCount); + metric.LastSaleUtc = DateTime.UtcNow; + } + + public void IncrementFailedSales(Guid merchantId) + { + var metric = Get(merchantId); + Interlocked.Increment(ref metric.FailedSales); + } + + public void SetBalance(Guid merchantId, decimal balance) + { + var metric = Get(merchantId); + metric.Balance = balance; + } + + public void SetLastEndOfDay(Guid merchantId) + { + var metric = Get(merchantId); + metric.LastEndOfDay = DateTime.UtcNow; + } +} + +public sealed class MerchantMetricSnapshot +{ + public int SalesCount; + public int FailedSales; + public decimal Balance; + public DateTime? LastSaleUtc; + public DateTime? LastEndOfDay; +} diff --git a/TransactionProcessing.MerchantPos/Runtime/MerchantPosSettingsSnapshot.cs b/TransactionProcessing.MerchantPos/Runtime/MerchantPosSettingsSnapshot.cs new file mode 100644 index 0000000..bef7e63 --- /dev/null +++ b/TransactionProcessing.MerchantPos/Runtime/MerchantPosSettingsSnapshot.cs @@ -0,0 +1,52 @@ +using Microsoft.Extensions.Configuration; + +namespace TransactionProcessing.MerchantPos.Runtime; + +public sealed class MerchantPosSettingsSnapshot +{ + public WorkerSettings WorkerSettings { get; set; } = new(); + public ApiConfigurationSettings ApiConfiguration { get; set; } = new(); + public ConnectionStringsSettings ConnectionStrings { get; set; } = new(); + + public static MerchantPosSettingsSnapshot FromConfiguration(IConfiguration configuration) + { + var snapshot = new MerchantPosSettingsSnapshot(); + configuration.Bind(snapshot); + snapshot.EnsureDefaults(); + return snapshot; + } + + public void EnsureDefaults() + { + WorkerSettings ??= new WorkerSettings(); + WorkerSettings.ClientId ??= string.Empty; + WorkerSettings.ClientSecret ??= string.Empty; + WorkerSettings.ServiceClientId ??= string.Empty; + WorkerSettings.ServiceClientSecret ??= string.Empty; + WorkerSettings.Merchants ??= new List(); + + ApiConfiguration ??= new ApiConfigurationSettings(); + ApiConfiguration.SecurityService ??= string.Empty; + ApiConfiguration.TransactionProcessorACL ??= string.Empty; + ApiConfiguration.TransactionProcessorApi ??= string.Empty; + ApiConfiguration.TestHost ??= string.Empty; + + ConnectionStrings ??= new ConnectionStringsSettings(); + ConnectionStrings.MerchantDb ??= string.Empty; + ConnectionStrings.SettingsDb ??= string.Empty; + } +} + +public sealed class ApiConfigurationSettings +{ + public string SecurityService { get; set; } = string.Empty; + public string TransactionProcessorACL { get; set; } = string.Empty; + public string TransactionProcessorApi { get; set; } = string.Empty; + public string TestHost { get; set; } = string.Empty; +} + +public sealed class ConnectionStringsSettings +{ + public string MerchantDb { get; set; } = string.Empty; + public string SettingsDb { get; set; } = string.Empty; +} diff --git a/TransactionProcessing.MerchantPos/Runtime/WorkerSettings.cs b/TransactionProcessing.MerchantPos/Runtime/WorkerSettings.cs index a9ac7ab..a873827 100644 --- a/TransactionProcessing.MerchantPos/Runtime/WorkerSettings.cs +++ b/TransactionProcessing.MerchantPos/Runtime/WorkerSettings.cs @@ -1,8 +1,8 @@ public class WorkerSettings { - public string ClientId { get; set; } - public string ClientSecret { get; set; } - public string ServiceClientId { get; set; } - public string ServiceClientSecret { get; set; } + public string ClientId { get; set; } = string.Empty; + public string ClientSecret { get; set; } = string.Empty; + public string ServiceClientId { get; set; } = string.Empty; + public string ServiceClientSecret { get; set; } = string.Empty; public List Merchants { get; set; } = new(); -} \ No newline at end of file +} diff --git a/TransactionProcessing.MerchantPos/TransactionProcessing.MerchantPos.csproj b/TransactionProcessing.MerchantPos/TransactionProcessing.MerchantPos.csproj index 22456ab..0c7ca09 100644 --- a/TransactionProcessing.MerchantPos/TransactionProcessing.MerchantPos.csproj +++ b/TransactionProcessing.MerchantPos/TransactionProcessing.MerchantPos.csproj @@ -4,6 +4,9 @@ enable enable + + + diff --git a/TransactionProcessing.MerchantPos/Web/MerchantPosViewModels.cs b/TransactionProcessing.MerchantPos/Web/MerchantPosViewModels.cs new file mode 100644 index 0000000..436ba4f --- /dev/null +++ b/TransactionProcessing.MerchantPos/Web/MerchantPosViewModels.cs @@ -0,0 +1,22 @@ +namespace TransactionProcessing.MerchantPos.Web; + +public sealed record MerchantDashboardViewModel( + DateTime GeneratedUtc, + string SettingsDbPath, + MerchantDashboardSummary Summary, + IReadOnlyList Merchants); + +public sealed record MerchantDashboardSummary( + int MerchantCount, + decimal TotalBalance, + int SalesCount, + int FailedSales); + +public sealed record MerchantDashboardRow( + Guid MerchantId, + string MerchantName, + decimal Balance, + int Sales, + int FailedSales, + DateTime? LastSaleUtc, + DateTime? LastEndOfDayUtc); diff --git a/TransactionProcessing.MerchantPos/WorkerHost.cs b/TransactionProcessing.MerchantPos/WorkerHost.cs index a089fa0..4f47b35 100644 --- a/TransactionProcessing.MerchantPos/WorkerHost.cs +++ b/TransactionProcessing.MerchantPos/WorkerHost.cs @@ -4,26 +4,32 @@ public class WorkerHost : BackgroundService { private readonly IServiceProvider ServiceProvider; - private readonly WorkerSettings Settings; + private readonly TransactionProcessing.MerchantPos.Persistence.MerchantPosSettingsStore SettingsStore; - public WorkerHost(IServiceProvider serviceProvider, WorkerSettings settings) + public WorkerHost(IServiceProvider serviceProvider, TransactionProcessing.MerchantPos.Persistence.MerchantPosSettingsStore settingsStore) { this.ServiceProvider = serviceProvider; - Settings = settings; + SettingsStore = settingsStore; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - Logger.LogInformation($"WorkerHost starting; Merchant count: {Settings.Merchants.Count}"); + var settings = SettingsStore.Current.WorkerSettings; + Logger.LogInformation($"WorkerHost starting; Merchant count: {settings.Merchants.Count}"); - List tasks = new List(); - foreach (MerchantConfig m in Settings.Merchants) + foreach (MerchantConfig m in settings.Merchants) { - tasks.Add(StartMerchantWorker((this.Settings.ServiceClientId, this.Settings.ServiceClientSecret), (this.Settings.ClientId, this.Settings.ClientSecret), - m, stoppingToken)); + _ = StartMerchantWorker((settings.ServiceClientId, settings.ServiceClientSecret), (settings.ClientId, settings.ClientSecret), + m, stoppingToken); } - await Task.WhenAll(tasks); + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + } } private async Task StartMerchantWorker((String clientId, String clientSecret) serviceClient, (String clientId, String clientSecret) posClient, MerchantConfig merchant, CancellationToken token) @@ -34,4 +40,4 @@ private async Task StartMerchantWorker((String clientId, String clientSecret) se _ = Task.Run(() => runtime.RunAsync(serviceClient, posClient, merchant, token), token); } -} \ No newline at end of file +} diff --git a/TransactionProcessing.MerchantPos/appsettings.json b/TransactionProcessing.MerchantPos/appsettings.json index fbe56fe..cde2477 100644 --- a/TransactionProcessing.MerchantPos/appsettings.json +++ b/TransactionProcessing.MerchantPos/appsettings.json @@ -1,30 +1,22 @@ { "WorkerSettings": { - //"ClientId": "mobileAppClient", - //"ClientSecret": "d192cbc46d834d0da90e8a9d50ded543", - //"ServiceClientId": "serviceClient", - //"ServiceClientSecret": "d192cbc46d834d0da90e8a9d50ded543", - //"Merchants": [ - // { - // "EstateId": "435613ac-a468-47a3-ac4f-649d89764c22", - // "MerchantId": "ab1c99fb-1c6c-4694-9a32-b71be5d1da33", - // "MerchantName": "Test Merchant 1", - // "Enabled": true, - // "ApplicationVersion": "1.0.5", - // "DeviceIdentifier": "testmerchant1device", - // "Username": "merchantuser@testmerchant1.co.uk", - // "Password": "123456", - // "SaleIntervalSeconds": 30, - // "FailureInjectionProbability": 0.05, - // "DepositThreshold": 100, - // "DepositAmount": 500, - // "ClosingTime": "20:55", - // "OpeningTime": "08:00" - // } - //] + "ClientId": "", + "ClientSecret": "", + "ServiceClientId": "", + "ServiceClientSecret": "", + "Merchants": [] + }, + + "ApiConfiguration": { + "SecurityService": "https://localhost:5001", + "TransactionProcessorACL": "http://localhost:5003", + "TransactionProcessorApi": "http://localhost:5002", + "TestHost": "http://localhost:9000" }, "ConnectionStrings": { + "MerchantDb": "C:\\Temp\\merchant.db", + "SettingsDb": "C:\\Temp\\merchant-pos-settings.db" }, "Logging": { diff --git a/TransactionProcessing.MerchantPos/appsettings.staging.json b/TransactionProcessing.MerchantPos/appsettings.staging.json index c1776e7..4343b69 100644 --- a/TransactionProcessing.MerchantPos/appsettings.staging.json +++ b/TransactionProcessing.MerchantPos/appsettings.staging.json @@ -63,7 +63,8 @@ "TestHost": "http://192.168.1.163:9000" }, "ConnectionStrings": { - "MerchantDb": "Data Source=merchant.db" + "MerchantDb": "G:\\Git\\TransactionProcessing\\SupportTools\\MerchantPosData\\staging-merchant.db", + "SettingsDb": "G:\\Git\\TransactionProcessing\\SupportTools\\MerchantPosData\\staging-merchant-pos-settings.db" }, "Logging": { diff --git a/TransactionProcessor.SystemSetupTool/EstateSetupFunctions.cs b/TransactionProcessor.SystemSetupTool/EstateSetupFunctions.cs index 7fcdd37..2a551fa 100644 --- a/TransactionProcessor.SystemSetupTool/EstateSetupFunctions.cs +++ b/TransactionProcessor.SystemSetupTool/EstateSetupFunctions.cs @@ -52,10 +52,10 @@ public async Task SetupEstate(CancellationToken cancellationToken) { if (createEstateResult.IsFailed) return ResultHelpers.CreateFailure(createEstateResult); this.EstateId = createEstateResult.Data; - - //var createUserResult = await this.CreateEstateUser(cancellationToken); - //if (createUserResult.IsFailed) - // return ResultHelpers.CreateFailure(createUserResult); + + var createUserResult = await this.CreateEstateUser(cancellationToken); + if (createUserResult.IsFailed) + return ResultHelpers.CreateFailure(createUserResult); var createOperatorsResult = await this.CreateOperators(cancellationToken); if (createOperatorsResult.IsFailed) @@ -408,7 +408,7 @@ private async Task UpdateMerchant(Merchant merchant, var userResult = await this.SecurityServiceClient.GetUsers(merchant.User.EmailAddress, cancellationToken); if (userResult.IsFailed) return ResultHelpers.CreateFailure(userResult); - if (userResult.Data == null) { + if (userResult.Data == null || userResult.Data.Count == 0) { CreateMerchantUserRequest createMerchantUserRequest = new CreateMerchantUserRequest { EmailAddress = merchant.User.EmailAddress, FamilyName = merchant.User.FamilyName, diff --git a/TransactionProcessor.SystemSetupTool/Program.cs b/TransactionProcessor.SystemSetupTool/Program.cs index 69aa60e..e532c68 100644 --- a/TransactionProcessor.SystemSetupTool/Program.cs +++ b/TransactionProcessor.SystemSetupTool/Program.cs @@ -68,7 +68,7 @@ static async Task Main(string[] args) { Program.ProjectionClient = new (settings); Program.PersistentSubscriptionsClient = new (settings); - Mode setupMode = Mode.FileProcessorSetup; + Mode setupMode = Mode.EstateSetup; String configFileName = "setupconfig.staging.json";