From 98a57ec4bd14d76090a1d2c892e261cd4a94bb22 Mon Sep 17 00:00:00 2001 From: Stuart Ferguson Date: Mon, 22 Dec 2025 16:58:52 +0000 Subject: [PATCH 1/2] update ignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 80ad04d..7125ff7 100644 --- a/.gitignore +++ b/.gitignore @@ -352,3 +352,6 @@ MigrationBackup/ .ionide/ /TransactionProcessor.HealthChecksUI/TransactionProcessor.HealthChecksUI/appsettings.development.json /TransactionProcessing.SchedulerService/TransactionProcessing.SchedulerService/appsettings.development.json +/TransactionProcessing.MerchantPos/merchant.db +/TransactionProcessing.MerchantPos/merchant.db-shm +/TransactionProcessing.MerchantPos/merchant.db-wal From fd03f165278ae48bd92f1082e704f7b4f6388bcf Mon Sep 17 00:00:00 2001 From: Stuart Ferguson Date: Mon, 22 Dec 2025 16:58:57 +0000 Subject: [PATCH 2/2] First Cut of Merchant Pos --- .github/workflows/createrelease.yml | 75 ++- .github/workflows/pullrequest.yml | 3 + .../Models/Entities.cs | 27 + .../Persistence/EfRepository.cs | 166 ++++++ .../Persistence/MerchantDbContext.cs | 19 + TransactionProcessing.MerchantPos/Program.cs | 259 +++++++++ .../Properties/launchSettings.json | 12 + TransactionProcessing.MerchantPos/README.md | 17 + .../Runtime/ApiClient.cs | 504 ++++++++++++++++++ .../Runtime/MerchantConfig.cs | 24 + .../Runtime/MerchantRuntime.cs | 231 ++++++++ .../Runtime/MerchantRuntimeFactory.cs | 26 + .../Runtime/Product.cs | 14 + .../Runtime/ResultExtensions.cs | 14 + .../Runtime/SaleResponse.cs | 3 + .../Runtime/WorkerSettings.cs | 8 + .../TransactionProcessing.MerchantPos.csproj | 23 + .../TransactionProcessing.MerchantPos.sln | 25 + .../WorkerHost.cs | 37 ++ .../appsettings.json | 38 ++ .../appsettings.staging.json | 72 +++ TransactionProcessing.MerchantPos/nlog.config | 39 ++ .../EstateSetupFunctions.cs | 5 +- .../Program.cs | 2 +- .../setupconfig.json | 85 +++ docker-compose-master.yml | 4 +- 26 files changed, 1718 insertions(+), 14 deletions(-) create mode 100644 TransactionProcessing.MerchantPos/Models/Entities.cs create mode 100644 TransactionProcessing.MerchantPos/Persistence/EfRepository.cs create mode 100644 TransactionProcessing.MerchantPos/Persistence/MerchantDbContext.cs create mode 100644 TransactionProcessing.MerchantPos/Program.cs create mode 100644 TransactionProcessing.MerchantPos/Properties/launchSettings.json create mode 100644 TransactionProcessing.MerchantPos/README.md create mode 100644 TransactionProcessing.MerchantPos/Runtime/ApiClient.cs create mode 100644 TransactionProcessing.MerchantPos/Runtime/MerchantConfig.cs create mode 100644 TransactionProcessing.MerchantPos/Runtime/MerchantRuntime.cs create mode 100644 TransactionProcessing.MerchantPos/Runtime/MerchantRuntimeFactory.cs create mode 100644 TransactionProcessing.MerchantPos/Runtime/Product.cs create mode 100644 TransactionProcessing.MerchantPos/Runtime/ResultExtensions.cs create mode 100644 TransactionProcessing.MerchantPos/Runtime/SaleResponse.cs create mode 100644 TransactionProcessing.MerchantPos/Runtime/WorkerSettings.cs create mode 100644 TransactionProcessing.MerchantPos/TransactionProcessing.MerchantPos.csproj create mode 100644 TransactionProcessing.MerchantPos/TransactionProcessing.MerchantPos.sln create mode 100644 TransactionProcessing.MerchantPos/WorkerHost.cs create mode 100644 TransactionProcessing.MerchantPos/appsettings.json create mode 100644 TransactionProcessing.MerchantPos/appsettings.staging.json create mode 100644 TransactionProcessing.MerchantPos/nlog.config diff --git a/.github/workflows/createrelease.yml b/.github/workflows/createrelease.yml index 3339725..e4bad7d 100644 --- a/.github/workflows/createrelease.yml +++ b/.github/workflows/createrelease.yml @@ -28,15 +28,18 @@ jobs: run: | dotnet restore TransactionProcessor.HealthChecksUI/TransactionProcessor.HealthChecksUI.sln --source ${{ secrets.PUBLICFEEDURL }} --source ${{ secrets.PRIVATEFEED_URL }} dotnet restore TransactionProcessing.SchedulerService/TransactionProcessing.SchedulerService.sln --source ${{ secrets.PUBLICFEEDURL }} --source ${{ secrets.PRIVATEFEED_URL }} + dotnet restore TransactionProcessing.MerchantPos/TransactionProcessing.MerchantPos.sln --source ${{ secrets.PUBLICFEEDURL }} --source ${{ secrets.PRIVATEFEED_URL }} - name: Build Code run: | dotnet build TransactionProcessor.HealthChecksUI/TransactionProcessor.HealthChecksUI.sln --configuration Release + dotnet build TransactionProcessor.MerchantPos/TransactionProcessor.MerchantPos.sln --configuration Release - name: Publish API run: | dotnet publish "TransactionProcessor.HealthChecksUI/TransactionProcessor.HealthChecksUI/TransactionProcessor.HealthChecksUI.csproj" --configuration Release --output TransactionProcessor.HealthChecksUI/publishOutput -r win-x64 --self-contained - + dotnet publish "TransactionProcessor.MerchantPos/TransactionProcessor.MerchantPos/TransactionProcessor.MerchantPos.csproj" --configuration Release --output TransactionProcessor.MerchantPos/publishOutput -r win-x64 --self-contained + - name: Build Release Package (Health Check UI) run: | cd /home/runner/work/SupportTools/SupportTools/TransactionProcessor.HealthChecksUI/publishOutput @@ -48,6 +51,18 @@ jobs: with: name: healthchecksui path: /home/runner/work/SupportTools/SupportTools/TransactionProcessor.HealthChecksUI/healthchecksui.zip + + - name: Build Release Package (Merchant Pos) + run: | + cd /home/runner/work/SupportTools/SupportTools/TransactionProcessor.MerchantPos/publishOutput + zip -r ../merchantpos.zip ./* + echo "Zip file created at: $(realpath ../merchantpos.zip)" + + - name: Upload the artifact (Merchant Pos) + uses: actions/upload-artifact@v4.4.0 + with: + name: merchantpos + path: /home/runner/work/SupportTools/SupportTools/TransactionProcessor.MerchantPos/merchantpos.zip deploystaging: runs-on: [stagingserver, windows] @@ -61,7 +76,7 @@ jobs: with: name: healthchecksui - - name: Remove existing Windows service + - name: Remove existing Windows service (Health Check UI) run: | $serviceName = "Transaction Processing - Health Checks UI" # Check if the service exists @@ -70,18 +85,39 @@ jobs: sc.exe delete $serviceName } - - name: Unzip the files + - name: Unzip the files (Health Check UI) run: | Expand-Archive -Path healthchecksui.zip -DestinationPath "C:\txnproc\transactionprocessing\healthchecksui" -Force - - name: Install as a Windows service + - name: Install as a Windows service (Health Check UI) run: | $serviceName = "Transaction Processing - Health Checks UI" $servicePath = "C:\txnproc\transactionprocessing\healthchecksui\TransactionProcessor.HealthChecksUI.exe" New-Service -Name $serviceName -BinaryPathName $servicePath -Description $serviceName -DisplayName $serviceName -StartupType Automatic - Start-Service -Name $serviceName + Start-Service -Name $serviceName + + - name: Remove existing Windows service (Merchant Pos) + run: | + $serviceName = "Transaction Processing - Merchant Pos" + # Check if the service exists + if (Get-Service -Name $serviceName -ErrorAction SilentlyContinue) { + Stop-Service -Name $serviceName + sc.exe delete $serviceName + } + + - name: Unzip the files (Merchant Pos) + run: | + Expand-Archive -Path merchantpos.zip -DestinationPath "C:\txnproc\transactionprocessing\merchantpos" -Force + - name: Install as a Windows service (Merchant Pos) + run: | + $serviceName = "Transaction Processing - Merchant Pos" + $servicePath = "C:\txnproc\transactionprocessing\merchantpos\TransactionProcessor.MerchantPos.exe" + + New-Service -Name $serviceName -BinaryPathName $servicePath -Description $serviceName -DisplayName $serviceName -StartupType Automatic + Start-Service -Name $serviceName + deployproduction: runs-on: [productionserver, windows] needs: [build, deploystaging] @@ -94,7 +130,7 @@ jobs: with: name: healthchecksui - - name: Remove existing Windows service + - name: Remove existing Windows service (Health Check UI) run: | $serviceName = "Transaction Processing - Health Checks UI" # Check if the service exists @@ -103,14 +139,35 @@ jobs: sc.exe delete $serviceName } - - name: Unzip the files + - name: Unzip the files (Health Check UI) run: | Expand-Archive -Path healthchecksui.zip -DestinationPath "C:\txnproc\transactionprocessing\healthchecksui" -Force - - name: Install as a Windows service + - name: Install as a Windows service (Health Check UI) run: | $serviceName = "Transaction Processing - Health Checks UI" $servicePath = "C:\txnproc\transactionprocessing\healthchecksui\TransactionProcessor.HealthChecksUI.exe" New-Service -Name $serviceName -BinaryPathName $servicePath -Description $serviceName -DisplayName $serviceName -StartupType Automatic - Start-Service -Name $serviceName + Start-Service -Name $serviceName + + - name: Remove existing Windows service (Merchant Pos) + run: | + $serviceName = "Transaction Processing - Merchant Pos" + # Check if the service exists + if (Get-Service -Name $serviceName -ErrorAction SilentlyContinue) { + Stop-Service -Name $serviceName + sc.exe delete $serviceName + } + + - name: Unzip the files (Merchant Pos) + run: | + Expand-Archive -Path merchantpos.zip -DestinationPath "C:\txnproc\transactionprocessing\merchantpos" -Force + + - name: Install as a Windows service (Merchant Pos) + run: | + $serviceName = "Transaction Processing - Merchant Pos" + $servicePath = "C:\txnproc\transactionprocessing\merchantpos\TransactionProcessor.MerchantPos.exe" + + New-Service -Name $serviceName -BinaryPathName $servicePath -Description $serviceName -DisplayName $serviceName -StartupType Automatic + Start-Service -Name $serviceName diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index d18b62f..0934dde 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -25,8 +25,11 @@ jobs: run: | dotnet restore TransactionProcessor.HealthChecksUI/TransactionProcessor.HealthChecksUI.sln --source ${{ secrets.PUBLICFEEDURL }} --source ${{ secrets.PRIVATEFEED_URL }} dotnet restore TransactionProcessing.SchedulerService/TransactionProcessing.SchedulerService.sln --source ${{ secrets.PUBLICFEEDURL }} --source ${{ secrets.PRIVATEFEED_URL }} + dotnet restore TransactionProcessing.MerchantPos/TransactionProcessing.MerchantPos.sln --source ${{ secrets.PUBLICFEEDURL }} --source ${{ secrets.PRIVATEFEED_URL }} + - name: Build Code run: | dotnet build TransactionProcessor.HealthChecksUI/TransactionProcessor.HealthChecksUI.sln --configuration Release dotnet build TransactionProcessing.SchedulerService/TransactionProcessing.SchedulerService.sln --configuration Release + dotnet build TransactionProcessing.MerchantPos/TransactionProcessing.MerchantPos.sln --configuration Release diff --git a/TransactionProcessing.MerchantPos/Models/Entities.cs b/TransactionProcessing.MerchantPos/Models/Entities.cs new file mode 100644 index 0000000..64a5938 --- /dev/null +++ b/TransactionProcessing.MerchantPos/Models/Entities.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace MerchantPos.EF.Models +{ + public class Merchant + { + [Key] + public Guid MerchantId { get; set; } + public String MerchantName { get; set; } + public Decimal Balance { get; set; } + public DateTime LastEndOfDayDateTime { get; set; } + public DateTime LastLogonDateTime { get; set; } + public Int32 TransactionNumber { get; set; } + } + + public class OperatorTotal + { + [Key] + public int Id { get; set; } + public Guid MerchantId { get; set; } + public Guid OperatorId { get; set; } + public Guid ContractId { get; set; } + public Decimal Total { get; set; } + public Int32 TotalCount { get; set; } + } +} diff --git a/TransactionProcessing.MerchantPos/Persistence/EfRepository.cs b/TransactionProcessing.MerchantPos/Persistence/EfRepository.cs new file mode 100644 index 0000000..a010e36 --- /dev/null +++ b/TransactionProcessing.MerchantPos/Persistence/EfRepository.cs @@ -0,0 +1,166 @@ +using MerchantPos.EF.Models; +using Microsoft.EntityFrameworkCore; + +namespace MerchantPos.EF.Persistence +{ + public interface IEfRepository + { + Task> GetAllMerchants(); + Task GetBalance(Guid merchantId); + Task CreateMerchantRecord(Guid merchantId, String merchantName); + Task UpdateBalance(Guid merchantId, String merchantName, Decimal balance); + Task UpdateLastEndOfDay(Guid merchantId, String merchantName, DateTime lastEndOfDayDateTime); + Task UpdateLastLogon(Guid merchantId, String merchantName, DateTime lastLogonDateTime); + Task UpdateTotals(Guid merchantId, Guid operatorId, Guid contractId, Decimal amount); + Task> GetTotals(Guid merchantId); + Task ClearTotals(Guid merchantId); + Task GetMerchant(Guid merchantId); + + Task IncrementTransactionNumber(Guid merchantId, String merchantName); + } + + public class EfRepository : IEfRepository + { + private readonly MerchantDbContext _db; + + public EfRepository(MerchantDbContext db) + { + _db = db; + } + + public async Task CreateMerchantRecord(Guid merchantId, + String merchantName) { + Merchant merchant = new Merchant(); + merchant.MerchantId = merchantId; + merchant.MerchantName = merchantName; + merchant.Balance = 0; + merchant.TransactionNumber = 0; + merchant.LastEndOfDayDateTime = DateTime.MinValue; + merchant.LastLogonDateTime = DateTime.MinValue; + _db.Merchants.Add(merchant); + await _db.SaveChangesAsync(); + return merchant; + } + + public async Task UpdateBalance(Guid merchantId,String merchantName, Decimal balance) + { + Merchant? entry = await this.GetMerchant(merchantId); + + if (entry == null) { + entry = await this.CreateMerchantRecord(merchantId, merchantName); + } + + entry.MerchantName = merchantName; + entry.Balance = balance; + + await _db.SaveChangesAsync(); + } + + public async Task GetMerchant(Guid merchantId) + { + return await _db.Merchants.FindAsync(merchantId); + } + + public async Task IncrementTransactionNumber(Guid merchantId, + String merchantName) { + Merchant? entry = await this.GetMerchant(merchantId); + + if (entry == null) + { + entry = await this.CreateMerchantRecord(merchantId, merchantName); + } + + Int32 nextTransactionNumber = entry.TransactionNumber + 1; + if (nextTransactionNumber == 9999) { + nextTransactionNumber = 1; + } + entry.TransactionNumber = nextTransactionNumber; + await _db.SaveChangesAsync(); + } + + public async Task UpdateLastEndOfDay(Guid merchantId, String merchantName, DateTime lastEndOfDayDateTime) + { + Merchant? entry = await this.GetMerchant(merchantId); + + if (entry == null) + { + entry = await this.CreateMerchantRecord(merchantId, merchantName); + } + entry.LastEndOfDayDateTime = lastEndOfDayDateTime; + + await _db.SaveChangesAsync(); + } + + public async Task UpdateLastLogon(Guid merchantId, + String merchantName, + DateTime lastLogonDateTime) { + Merchant? entry = await this.GetMerchant(merchantId); + + if (entry == null) + { + entry = await this.CreateMerchantRecord(merchantId, merchantName); + } + entry.LastLogonDateTime = lastLogonDateTime; + + await _db.SaveChangesAsync(); + } + + + public async Task> GetAllMerchants() { + var entries = await _db.Merchants.ToListAsync(); + return entries.ToList(); + } + + public async Task GetBalance(Guid merchantId) + { + Merchant? entry = await this.GetMerchant(merchantId); + return entry?.Balance ?? 0; + } + + public async Task GetLastEndOfDay(Guid merchantId) + { + Merchant? entry = await this.GetMerchant(merchantId); + return entry?.LastEndOfDayDateTime ?? DateTime.MinValue; + } + + public async Task UpdateTotals(Guid merchantId, Guid operatorId, Guid contractId, decimal amount) + { + OperatorTotal? entry = await _db.OperatorTotals + .SingleOrDefaultAsync(o => o.MerchantId == merchantId && o.OperatorId == operatorId + && o.ContractId == contractId); + + if (entry == null) + { + entry = new OperatorTotal + { + MerchantId = merchantId, + OperatorId = operatorId, + ContractId = contractId, + Total = amount + }; + _db.OperatorTotals.Add(entry); + } + else + { + entry.Total += amount; + } + + await _db.SaveChangesAsync(); + } + + public async Task> GetTotals(Guid merchantId) + { + return await _db.OperatorTotals + .Where(o => o.MerchantId == merchantId) + .ToListAsync(); + } + + public async Task ClearTotals(Guid merchantId) + { + IQueryable rows = _db.OperatorTotals.Where(o => o.MerchantId == merchantId); + _db.OperatorTotals.RemoveRange(rows); + await _db.SaveChangesAsync(); + } + } +} + diff --git a/TransactionProcessing.MerchantPos/Persistence/MerchantDbContext.cs b/TransactionProcessing.MerchantPos/Persistence/MerchantDbContext.cs new file mode 100644 index 0000000..da616d5 --- /dev/null +++ b/TransactionProcessing.MerchantPos/Persistence/MerchantDbContext.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; +using MerchantPos.EF.Models; + +namespace MerchantPos.EF.Persistence +{ + public class MerchantDbContext : DbContext + { + public MerchantDbContext(DbContextOptions opts) : base(opts) { } + + public DbSet Merchants { get; set; } + public DbSet OperatorTotals { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasKey(m => m.MerchantId); + modelBuilder.Entity().HasIndex(o => new { o.MerchantId, o.OperatorId }); + } + } +} diff --git a/TransactionProcessing.MerchantPos/Program.cs b/TransactionProcessing.MerchantPos/Program.cs new file mode 100644 index 0000000..5a4a4f0 --- /dev/null +++ b/TransactionProcessing.MerchantPos/Program.cs @@ -0,0 +1,259 @@ +using MerchantPos.EF.Models; +using MerchantPos.EF.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using SecurityService.Client; +using System.Collections.Concurrent; +using System.Text.Json; +using NLog; +using NLog.Extensions.Logging; +using NLog.Web; +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; + + var builder = WebApplication.CreateBuilder(new WebApplicationOptions + { + Args = args, + ContentRootPath = Directory.GetCurrentDirectory(), + EnvironmentName = envName + }); + + // Explicit configuration ordering: appsettings.json, appsettings.{Environment}.json, environment vars, command line + builder.Configuration + .SetBasePath(builder.Environment.ContentRootPath) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true) + .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.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(); + + 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.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddHttpClient(); + + builder.Services.AddScoped(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton>( + new Func(configSetting => + { + return configSetting switch + { + "SecurityService" => "https://localhost:5001", + "TransactionProcessorACL" => "http://localhost:5003", + "TransactionProcessorApi" => "http://localhost:5002", + "EstateReportingApi" => "http://localhost:5004", + "TestHost" => "http://localhost:9000", + _ => string.Empty, + }; + })); + // 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(); + + // Auto-create SQLite database and tables + using (var scope = app.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); + + var diLogger = scope.ServiceProvider.GetRequiredService>(); + Shared.Logger.Logger.Initialise(diLogger); + } + + 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"); + + while (!ctx.RequestAborted.IsCancellationRequested) + { + var balances = await repo.GetAllMerchants(); + + 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.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/Properties/launchSettings.json b/TransactionProcessing.MerchantPos/Properties/launchSettings.json new file mode 100644 index 0000000..2279453 --- /dev/null +++ b/TransactionProcessing.MerchantPos/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "MerchantPos.EF": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:58435;http://localhost:58436" + } + } +} \ No newline at end of file diff --git a/TransactionProcessing.MerchantPos/README.md b/TransactionProcessing.MerchantPos/README.md new file mode 100644 index 0000000..6455102 --- /dev/null +++ b/TransactionProcessing.MerchantPos/README.md @@ -0,0 +1,17 @@ +# Merchant POS with EF Core and Health Dashboard + +This skeleton demonstrates: +- EF Core (SQLite) persistent store (`merchantpos.db`) +- Minimal health endpoint at `/health` +- Dashboard endpoint at `/dashboard` (returns balances JSON) +- DbContext & repository abstraction `IEfRepository` / `EfRepository` + +How to run: +- `dotnet restore` +- `dotnet build` +- `dotnet run` +- Visit `http://localhost:5000/health` and `/dashboard` + +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`. diff --git a/TransactionProcessing.MerchantPos/Runtime/ApiClient.cs b/TransactionProcessing.MerchantPos/Runtime/ApiClient.cs new file mode 100644 index 0000000..5942fc0 --- /dev/null +++ b/TransactionProcessing.MerchantPos/Runtime/ApiClient.cs @@ -0,0 +1,504 @@ +using System.Net.Http.Headers; +using System.Text; +using MerchantPos.EF.Models; +using Newtonsoft.Json; +using SecurityService.Client; +using SecurityService.DataTransferObjects.Responses; +using Shared.Logger; +using SimpleResults; +using TransactionProcessor.Client; +using TransactionProcessor.DataTransferObjects; +using TransactionProcessor.DataTransferObjects.Requests.Merchant; +using TransactionProcessorACL.DataTransferObjects; +using TransactionProcessorACL.DataTransferObjects.Responses; +using OperatorTotalRequest = TransactionProcessorACL.DataTransferObjects.OperatorTotalRequest; + +namespace TransactionProcessing.MerchantPos.Runtime; + +public interface IApiClient +{ + Task> GetToken(String clientId, String clientSecret, MerchantConfig cfg, CancellationToken cancellationToken); + Task SendLogon(MerchantConfig cfg, TokenResponse token, + Int32 transactionNumber, CancellationToken cancellationToken); + Task> GetProductList(MerchantConfig cfg, TokenResponse token, CancellationToken cancellationToken); + Task GetBalance(MerchantConfig cfg, TokenResponse token, CancellationToken cancellationToken); + Task SendSale(MerchantConfig cfg, TokenResponse token, Product product, Decimal value, + Int32 transactionNumber, CancellationToken cancellationToken); + Task SendDeposit(MerchantConfig cfg, TokenResponse token, decimal amount, CancellationToken cancellationToken); + Task SendReconciliation(MerchantConfig cfg, TokenResponse token, List totals, CancellationToken cancellationToken); +} + +public class ApiClient : ClientProxyBase.ClientProxyBase, IApiClient { + private readonly ISecurityServiceClient SecurityClient; + private readonly ITransactionProcessorClient TransactionProcessorClient; + private readonly Func BaseAddressResolver; + + public ApiClient(ISecurityServiceClient securityClient, + ITransactionProcessorClient transactionProcessorClient, + HttpClient httpClient, + Func baseAddressResolver) : base(httpClient) { + this.SecurityClient = securityClient; + this.TransactionProcessorClient = transactionProcessorClient; + this.BaseAddressResolver = baseAddressResolver; + } + + public async Task> GetToken(String clientId, + String clientSecret, + MerchantConfig cfg, + CancellationToken cancellationToken) { + return await this.SecurityClient.GetToken(cfg.Username, cfg.Password, clientId, clientSecret, cancellationToken); + } + + public async Task SendLogon(MerchantConfig cfg, + TokenResponse token, + Int32 transactionNumber, + CancellationToken cancellationToken) { + + LogonTransactionRequestMessage logonTransactionRequest = new() { + ApplicationVersion = cfg.ApplicationVersion, + DeviceIdentifier = cfg.DeviceIdentifier, + TransactionDateTime = DateTime.Now, + TransactionNumber = transactionNumber.ToString("D4") + }; + + Result result = await this.SendTransactionRequest(logonTransactionRequest, "api/logontransactions", token, cancellationToken); + + if (result.IsFailed) + Logger.LogWarning($"Logon failed for Merchant {cfg.MerchantName}"); + } + + public async Task> GetProductList(MerchantConfig cfg, + TokenResponse token, + CancellationToken cancellationToken) { + List products = new(); + + Guid estateId = cfg.EstateId; + Guid merchantId = cfg.MerchantId; + + String requestUri = this.BuildRequestUrl($"api/merchants/contracts?applicationVersion={cfg.ApplicationVersion}"); + + Logger.LogInformation("About to request merchant contracts"); + Logger.LogDebug($"Merchant Contract Request details: Estate Id {estateId} Merchant Id {merchantId} Access Token {token.AccessToken}"); + + HttpRequestMessage request = new(HttpMethod.Get, requestUri); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken); + var httpResponse = await this.HttpClient.SendAsync(request, cancellationToken); + + // Process the response + Result content = await this.HandleResponseX(httpResponse, cancellationToken); + + if (content.IsFailed) + { + Logger.LogInformation($"GetMerchantContracts failed {content.Status}"); + } + + Logger.LogDebug($"Transaction Response details: Status {httpResponse.StatusCode} Payload {content.Data}"); + + List? responseData = JsonConvert.DeserializeObject>(content.Data); + + responseData = responseData.Where(r => r.ContractId == Guid.Parse("881f5e96-deac-45a5-a9cf-69977a5af559")).ToList(); + + Logger.LogInformation($"{responseData.Count} for merchant requested successfully"); + Logger.LogDebug($"Merchant Contract Response: [{JsonConvert.SerializeObject(responseData)}]"); + + foreach (ContractResponseX contractResponse in responseData) + { + foreach (ContractProductX contractResponseProduct in contractResponse.Products) + { + products.Add(new Product { + ContractId = contractResponse.ContractId, + OperatorId = contractResponse.OperatorId, + ProductId = contractResponseProduct.ProductId, + ProductType = GetProductType(contractResponse.OperatorName), + Value = contractResponseProduct.Value ?? 0, + Name = contractResponseProduct.Name, + ProductSubType = GetProductSubType(contractResponse.OperatorName), + }); + } + } + + return products; + } + + public static ProductType GetProductType(String operatorName) + { + return operatorName switch + { + "Safaricom" => ProductType.MobileTopup, + "Voucher" => ProductType.Voucher, + "PataPawa PostPay" => ProductType.BillPayment, + "PataPawa PrePay" => ProductType.BillPayment, + _ => ProductType.NotSet, + }; + } + + public static ProductSubType GetProductSubType(String operatorName) + { + return operatorName switch + { + "Safaricom" => ProductSubType.MobileTopup, + "Voucher" => ProductSubType.Voucher, + "PataPawa PostPay" => ProductSubType.BillPaymentPostPay, + "PataPawa PrePay" => ProductSubType.BillPaymentPrePay, + _ => ProductSubType.NotSet, + }; + } + + public async Task GetBalance(MerchantConfig cfg, + TokenResponse token, + CancellationToken cancellationToken) { + Result? response = await this.TransactionProcessorClient.GetMerchantBalance(token.AccessToken, cfg.EstateId, cfg.MerchantId, cancellationToken); + + if (response.IsFailed) { + Logger.LogWarning($"Error retrieving merchant balance for merchant {cfg.MerchantName}"); + } + + return response.Data.Balance; + } + + public async Task SendSale(MerchantConfig cfg, + TokenResponse token, + Product product, + Decimal value, + Int32 transactionNumber, + CancellationToken cancellationToken) { + + + List requests = product.ProductType switch { + ProductType.MobileTopup => BuildMobileSaleTransactionRequestMessage(cfg, product, value, transactionNumber), + ProductType.Voucher => BuildVoucherTransactionRequestMessage(cfg, product, value, transactionNumber), + ProductType.BillPayment => await BuildBillPaymentTransactionRequestMessages(cfg, product, value, transactionNumber), + _ => throw new NotImplementedException($"Product Type {product.ProductType} not implemented") + }; + Boolean approved = false; + foreach (SaleTransactionRequestMessage saleTransactionRequestMessage in requests) { + Result response = await this.SendTransactionRequest(saleTransactionRequestMessage, "api/saletransactions", token, cancellationToken); + if (response.IsSuccess == false) { + // Exit the loop on failure + approved = false; + break; + } + approved = response.Data.ResponseCode == "0000"; + } + + return new SaleResponse(approved); + } + + public async Task SendDeposit(MerchantConfig cfg, + TokenResponse token, + Decimal amount, + CancellationToken cancellationToken) { + var result = await this.TransactionProcessorClient.MakeMerchantDeposit(token.AccessToken, cfg.EstateId, cfg.MerchantId, new MakeMerchantDepositRequest { + Amount = amount, + DepositDateTime = DateTime.Now, + Reference = $"AutoDeposit{DateTime.Now:yyyy-MM-dd}" + }, cancellationToken); + + if (result.IsFailed) + Logger.LogWarning($"Error performing deposit for merchant {cfg.MerchantName}"); + } + + public async Task SendReconciliation(MerchantConfig cfg, + TokenResponse token, + List totals, + CancellationToken cancellationToken) { + ReconciliationRequestMessage reconciliationRequest = new ReconciliationRequestMessage + { + ApplicationVersion = cfg.ApplicationVersion, + TransactionDateTime = DateTime.Now, + DeviceIdentifier = cfg.DeviceIdentifier, + TransactionCount = totals.Sum(t=> t.TotalCount), + TransactionValue = totals.Sum(t => t.Total), + OperatorTotals = new List() + }; + foreach (var modelOperatorTotal in totals) + { + reconciliationRequest.OperatorTotals.Add(new OperatorTotalRequest + { + OperatorId = modelOperatorTotal.OperatorId, + TransactionValue = modelOperatorTotal.Total, + ContractId = modelOperatorTotal.ContractId, + TransactionCount = modelOperatorTotal.TotalCount + }); + } + + Result result = await this.SendTransactionRequest(reconciliationRequest, "api/reconciliationtransactions", token, cancellationToken); + + if (result.IsFailed) + Logger.LogWarning($"Error during reconciliation for merchant {cfg.MerchantName}"); + } + + private async Task> BuildBillPaymentTransactionRequestMessages(MerchantConfig cfg, + Product product, + Decimal value, + Int32 transactionNumber) { + // We need data setup at test host here for both post pay and pre pay bill payment products + (String accountNumber, String accountName, String mobileNumber) extraDetails = product.ProductSubType switch { + ProductSubType.BillPaymentPostPay => await this.CreateBillPaymentBill(value, CancellationToken.None), + ProductSubType.BillPaymentPrePay => await this.CreateBillPaymentMeter(CancellationToken.None), + }; + + List requestMessages = new(); + SaleTransactionRequestMessage getTransactionRequestMessage = new() { + ProductId = product.ProductId, + OperatorId = product.OperatorId, + ApplicationVersion = cfg.ApplicationVersion, + DeviceIdentifier = cfg.DeviceIdentifier, + ContractId = product.ContractId, + TransactionDateTime = DateTime.Now, + TransactionNumber = transactionNumber.ToString("D4") + }; + + if (product.ProductSubType == ProductSubType.BillPaymentPostPay) { + getTransactionRequestMessage.AdditionalRequestMetadata = new Dictionary { { "CustomerAccountNumber", extraDetails.accountNumber }, { "PataPawaPostPaidMessageType", "VerifyAccount" } }; + + } + else if (product.ProductSubType == ProductSubType.BillPaymentPrePay) { + getTransactionRequestMessage.AdditionalRequestMetadata = new Dictionary { { "MeterNumber", extraDetails.accountNumber }, { "PataPawaPrePayMessageType", "meter" } }; + } + requestMessages.Add(getTransactionRequestMessage); + + // Now the actual payment request + SaleTransactionRequestMessage paymentRequestMessage = new() { + ProductId = product.ProductId, + OperatorId = product.OperatorId, + ApplicationVersion = cfg.ApplicationVersion, + DeviceIdentifier = cfg.DeviceIdentifier, + ContractId = product.ContractId, + TransactionDateTime = DateTime.Now, + TransactionNumber = transactionNumber.ToString("D4") + }; + + if (product.ProductSubType == ProductSubType.BillPaymentPostPay) { + // Add the additional request data + paymentRequestMessage.AdditionalRequestMetadata = new Dictionary { + { "CustomerAccountNumber", extraDetails.accountNumber }, + { "CustomerName", extraDetails.accountName }, + { "MobileNumber", extraDetails.mobileNumber }, + { "Amount", value.ToString() }, + { "PataPawaPostPaidMessageType", "ProcessBill" } + }; + } + else { + paymentRequestMessage.AdditionalRequestMetadata = new Dictionary { + { "MeterNumber", extraDetails.accountNumber }, { "CustomerName", extraDetails.accountName }, { "PataPawaPrePayMessageType", "vend" }, { "Amount", value.ToString() }, + }; + } + requestMessages.Add(paymentRequestMessage); + return requestMessages; + } + + private List BuildMobileSaleTransactionRequestMessage(MerchantConfig cfg, + Product product, + Decimal value, + Int32 transactionNumber) { + List requestMessages = new(); + SaleTransactionRequestMessage saleTransactionRequest = new() { + ProductId = product.ProductId, + OperatorId = product.OperatorId, + ApplicationVersion = cfg.ApplicationVersion, + DeviceIdentifier = cfg.DeviceIdentifier, + ContractId = product.ContractId, + TransactionDateTime = DateTime.Now, + TransactionNumber = transactionNumber.ToString("D4") + }; + + saleTransactionRequest.AdditionalRequestMetadata = new Dictionary { { "Amount", value.ToString() }, { "CustomerAccountNumber", "07777777705" } }; + requestMessages.Add(saleTransactionRequest); + return requestMessages; + } + + private List BuildVoucherTransactionRequestMessage(MerchantConfig cfg, + Product product, + Decimal value, + Int32 transactionNumber) + { + List requestMessages = new(); + SaleTransactionRequestMessage saleTransactionRequest = new() + { + ProductId = product.ProductId, + OperatorId = product.OperatorId, + ApplicationVersion = cfg.ApplicationVersion, + DeviceIdentifier = cfg.DeviceIdentifier, + ContractId = product.ContractId, + TransactionDateTime = DateTime.Now, + TransactionNumber = transactionNumber.ToString("D4") + }; + + saleTransactionRequest.AdditionalRequestMetadata = new Dictionary { + { "Amount", value.ToString() }, + { "RecipientMobile", "07777777705" } + }; + requestMessages.Add(saleTransactionRequest); + return requestMessages; + } + + private async Task> SendTransactionRequest(TRequest request, + String route, + TokenResponse tokenResponse, + CancellationToken cancellationToken) { + String requestUri = this.BuildRequestUrl(route); + try { + String requestSerialised = JsonConvert.SerializeObject(request); + + StringContent httpContent = new StringContent(requestSerialised, Encoding.UTF8, "application/json"); + + this.HttpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokenResponse.AccessToken); + + // Make the Http Call here + HttpResponseMessage httpResponse = await this.HttpClient.PostAsync(requestUri, httpContent, cancellationToken); + + // Process the response + Result result = await this.HandleResponseX(httpResponse, cancellationToken); + + if (result.IsSuccess == false) { + return Result.Failure("Error performing Voucher transaction"); + } + + TResponse? responseData = JsonConvert.DeserializeObject(result.Data); + + return Result.Success(responseData); + } + catch (Exception ex) { + // An exception has occurred, add some additional information to the message + + return ResultExtensions.FailureExtended("Error posting transaction", ex); + } + } + + private String BuildRequestUrl(String route) { + String baseAddress = this.BaseAddressResolver("TransactionProcessorACL"); + + String requestUri = $"{baseAddress}/{route}"; + + return requestUri; + } + private readonly Random _rng = new(); + private async Task<(String accountNumber, String accountName, String mobileNumber)> CreateBillPaymentBill(Decimal billAmount, CancellationToken cancellationToken) + { + Int32 accountNumber = this._rng.Next(1, 100000); + String baseAddress = this.BaseAddressResolver("TestHost"); + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, $"{baseAddress}/api/developer/patapawapostpay/createbill"); + var body = new + { + due_date = DateTime.Now.AddDays(1), + amount = billAmount, + account_number = accountNumber, + account_name = $"Test Account {accountNumber}" + }; + request.Content = new StringContent(JsonConvert.SerializeObject(body), Encoding.UTF8, "application/json"); + + + using (HttpClient client = new HttpClient()) + { + await client.SendAsync(request, cancellationToken); + } + + return (body.account_number.ToString(), body.account_name, "07777777705"); + + } + + private async Task<(String accountNumber, String accountName, String mobileNumber)> CreateBillPaymentMeter(CancellationToken cancellationToken) + { + Int32 meterNumber = this._rng.Next(1, 100000); + String baseAddress = this.BaseAddressResolver("TestHost"); + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, $"{baseAddress}/api/developer/patapawaprepay/createmeter"); + var body = new + { + due_date = DateTime.Now.AddDays(1), + meter_number = meterNumber, + customer_name = $"Customer {meterNumber}" + }; + request.Content = new StringContent(JsonConvert.SerializeObject(body), Encoding.UTF8, "application/json"); + + using (HttpClient client = new HttpClient()) + { + await client.SendAsync(request, cancellationToken); + } + + return (body.meter_number.ToString(), body.customer_name, null); + } + +} + +public class ContractResponseX +{ + [JsonProperty("contract_id")] + public Guid ContractId { get; set; } + + [JsonProperty("contract_reporting_id")] + public int ContractReportingId { get; set; } + + [JsonProperty("description")] + public string Description { get; set; } + + [JsonProperty("estate_id")] + public Guid EstateId { get; set; } + + [JsonProperty("estate_reporting_id")] + public int EstateReportingId { get; set; } + + [JsonProperty("operator_id")] + public Guid OperatorId { get; set; } + + [JsonProperty("operator_name")] + public string OperatorName { get; set; } + + [JsonProperty("products")] + public List Products { get; set; } +} + +public class ContractProductX +{ + [JsonProperty("display_text")] + public string DisplayText { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("product_id")] + public Guid ProductId { get; set; } + + [JsonProperty("product_reporting_id")] + public int ProductReportingId { get; set; } + + [JsonProperty("transaction_fees")] + public List TransactionFees { get; set; } + + [JsonProperty("value")] + public Decimal? Value { get; set; } + + [JsonProperty("product_type")] + public ProductType ProductType { get; set; } +} + +public class ContractProductTransactionFeeX +{ + [JsonProperty("calculation_type")] + public CalculationType CalculationType { get; set; } + + [JsonProperty("fee_type")] + public FeeType FeeType { get; set; } + + [JsonProperty("description")] + public string Description { get; set; } + + [JsonProperty("transaction_fee_id")] + public Guid TransactionFeeId { get; set; } + + [JsonProperty("transaction_fee_reporting_id")] + public int TransactionFeeReportingId { get; set; } + + [JsonProperty("value")] + public Decimal Value { get; set; } +} + +public enum ProductSubType +{ + NotSet = 0, + MobileTopup, + MobileWallet, + BillPaymentPostPay, + BillPaymentPrePay, + Voucher +} \ No newline at end of file diff --git a/TransactionProcessing.MerchantPos/Runtime/MerchantConfig.cs b/TransactionProcessing.MerchantPos/Runtime/MerchantConfig.cs new file mode 100644 index 0000000..f889ae0 --- /dev/null +++ b/TransactionProcessing.MerchantPos/Runtime/MerchantConfig.cs @@ -0,0 +1,24 @@ +using TransactionProcessing.MerchantPos.Runtime; + +public class MerchantConfig +{ + public bool Enabled { get; set; } = false; // new flag + public Guid EstateId { get; set; } + public Guid MerchantId { get; set; } + public string MerchantName { get; set; } + public string Username { get; set; } + public string Password { get; set; } + public string DeviceIdentifier { get; set; } + public string ApplicationVersion { get; set; } + public int SaleIntervalSeconds { get; set; } = 30; + + public double FailureInjectionProbability { get; set; } = 0.02; + + public decimal DepositThreshold { get; set; } = 100; + public decimal DepositAmount { get; set; } = 500; + + public TimeOnly ClosingTime { get; set; } = new(23, 50); + public TimeOnly OpeningTime { get; set; } = new(8, 0); + public List Products { get; set; } + public Boolean RequiresEndOfDay { get; set; } = true; +} \ No newline at end of file diff --git a/TransactionProcessing.MerchantPos/Runtime/MerchantRuntime.cs b/TransactionProcessing.MerchantPos/Runtime/MerchantRuntime.cs new file mode 100644 index 0000000..f86ffcf --- /dev/null +++ b/TransactionProcessing.MerchantPos/Runtime/MerchantRuntime.cs @@ -0,0 +1,231 @@ +using MerchantPos.EF.Persistence; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using SecurityService.DataTransferObjects.Responses; +using SimpleResults; +using System.Threading; +using Microsoft.EntityFrameworkCore.Design; +using SecurityService.Client; +using Shared.Logger; +using Shared.Results; +using TransactionProcessing.MerchantPos.Runtime; + +public class MerchantRuntime +{ + private readonly IApiClient ApiClient; + private readonly ISecurityServiceClient SecurityServiceClient; + private readonly IEfRepository Repository; + private readonly MerchantMetrics Metrics; + private readonly Random _rng = new(); + + public MerchantRuntime(IApiClient apiClient, + ISecurityServiceClient securityServiceClient, + IEfRepository repository, + MerchantMetrics metrics) + { + ApiClient = apiClient; + this.SecurityServiceClient = securityServiceClient; + Repository = repository; + this.Metrics = metrics; + } + + private TokenResponse CurrentServiceToken; + public async Task RunAsync((String clientId, String clientSecret) serviceClient, (String clientId, String clientSecret) posClient, MerchantConfig config, CancellationToken cancellationToken) + { + Logger.LogInformation($"MerchantRuntime started for {config.MerchantName}"); + + while (!cancellationToken.IsCancellationRequested) + { + try + { + // Get the service client token here ( can manage the expiry/caching at this level) + Result serviceToken = await this.GetToken(this.CurrentServiceToken, serviceClient, cancellationToken); + this.CurrentServiceToken = serviceToken.Data; + + await StartupSequence(posClient.clientId, posClient.clientSecret, config, cancellationToken); + await RunMainLoop(posClient.clientId, posClient.clientSecret, config, cancellationToken); + } + catch (Exception ex) + { + Logger.LogError($"Runtime crashed for merchant {config.MerchantName}. Restarting in 5s...", ex); + await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); + } + } + } + + private async Task> GetToken(TokenResponse currentToken, (String clientId, String clientSecret) serviceClient, + CancellationToken cancellationToken) + { + if (currentToken == null) + { + Result tokenResult = await this.SecurityServiceClient.GetToken(serviceClient.clientId, serviceClient.clientSecret, cancellationToken); + if (tokenResult.IsFailed) + return ResultHelpers.CreateFailure(tokenResult); + TokenResponse token = tokenResult.Data; + Logger.LogDebug($"Token is {token.AccessToken}"); + return Result.Success(token); + } + + if (currentToken.Expires.UtcDateTime.Subtract(DateTime.UtcNow) < TimeSpan.FromMinutes(2)) + { + Logger.LogDebug($"Token is about to expire at {currentToken.Expires.DateTime:O}"); + Result tokenResult = await this.SecurityServiceClient.GetToken(serviceClient.clientId, serviceClient.clientSecret, cancellationToken); + if (tokenResult.IsFailed) + return ResultHelpers.CreateFailure(tokenResult); + TokenResponse token = tokenResult.Data; + Logger.LogDebug($"Token is {token.AccessToken}"); + return Result.Success(token); + } + + return Result.Success(currentToken); + } + + private async Task StartupSequence(String clientId, + String clientSecret, + MerchantConfig cfg, + CancellationToken cancellationToken) { + // 1. Token + Result tokenResult = await this.ApiClient.GetToken(clientId, clientSecret, cfg, cancellationToken); + + if (tokenResult.IsFailed) { + Logger.LogWarning($"Failed to get token for merchant {cfg.MerchantName} during startup sequence."); + return; + } + + // 2. Load products + var products = await ApiClient.GetProductList(cfg, tokenResult.Data, cancellationToken); + cfg.Products = products; + + // 3. Balance + decimal balance = await ApiClient.GetBalance(cfg, this.CurrentServiceToken, cancellationToken); + await Repository.UpdateBalance(cfg.MerchantId,cfg.MerchantName, balance); + } + + private async Task RunMainLoop(String clientId, + String clientSecret, + MerchantConfig cfg, + CancellationToken token) { + TimeSpan saleInterval = TimeSpan.FromSeconds(cfg.SaleIntervalSeconds); + + while (!token.IsCancellationRequested) { + var merchant = await this.Repository.GetMerchant(cfg.MerchantId); + // Wait until the merchant's configured opening time + var currentTime = DateTime.Now; + var openingTime = cfg.OpeningTime.ToTimeSpan(); + var closingTime = cfg.ClosingTime.ToTimeSpan(); + if (currentTime.TimeOfDay < openingTime) { + TimeSpan delay = openingTime - currentTime.TimeOfDay; + Logger.LogInformation($"Merchant {cfg.MerchantName} sleeping until opening time {cfg.OpeningTime}"); + await Task.Delay(delay, token); + } + + if (currentTime.TimeOfDay > closingTime) { + // Get last end of day time + + if (currentTime.Date > merchant.LastEndOfDayDateTime.Date) { + await DoReconciliation(clientId, clientSecret, cfg, token); + } + + TimeSpan delay = openingTime - currentTime.TimeOfDay; + if (delay < TimeSpan.Zero) { + delay += TimeSpan.FromDays(1); + } + + Logger.LogInformation($"Merchant {cfg.MerchantName} sleeping until opening time {cfg.OpeningTime}"); + await Task.Delay(delay, token); + } + + + if (!cfg.Enabled) { + Logger.LogInformation($"Merchant {cfg.MerchantName} is disabled. Sleeping."); + await Task.Delay(TimeSpan.FromSeconds(30), token); + continue; + } + + var now = DateTime.Now; + if (merchant.LastLogonDateTime.Date != now.Date) + { + var tokenResult = await this.ApiClient.GetToken(clientId, clientSecret, cfg, token); + if (tokenResult.IsFailed) + { + Logger.LogWarning($"Failed to obtain token for daily logon for merchant {cfg.MerchantName}"); + } + else + { + await ApiClient.SendLogon(cfg, tokenResult.Data, merchant.TransactionNumber, token); + //_lastDailyLogonDate = now.Date; + await this.Repository.UpdateLastLogon(cfg.MerchantId, cfg.MerchantName, now); + Logger.LogInformation($"Performed daily logon for merchant {cfg.MerchantName} on {now:yyyy-MM-dd}"); + } + } + else { + // Sell product + await DoSaleCycle(clientId, clientSecret, cfg, merchant.TransactionNumber, token); + } + + await this.Repository.IncrementTransactionNumber(cfg.MerchantId, cfg.MerchantName); + await Task.Delay(saleInterval, token); + } + } + + private async Task DoSaleCycle(String clientId, String clientSecret, MerchantConfig cfg,Int32 transactionNumber, CancellationToken cancellationToken) + { + Result tokenResult = await this.ApiClient.GetToken(clientId, clientSecret, cfg, cancellationToken); + if (tokenResult.IsFailed) + return; + + decimal balance = await Repository.GetBalance(cfg.MerchantId); + + // Random product + Product product = cfg.Products[_rng.Next(cfg.Products.Count)]; + + Decimal value = product.Value switch + { + 0 => this._rng.Next(9, 250), + _ => product.Value + }; + + // Possible intentional fail + bool induceFail = _rng.NextDouble() < cfg.FailureInjectionProbability; + decimal saleValue = induceFail ? balance + 10 : value; + + SaleResponse result = await ApiClient.SendSale(cfg, tokenResult.Data, product, saleValue, transactionNumber, cancellationToken); + + if (result.Authorised) + { + Metrics.IncrementSales(cfg.MerchantId); + await Repository.UpdateTotals(cfg.MerchantId, product.OperatorId, product.ContractId, saleValue); + await Repository.UpdateBalance(cfg.MerchantId, cfg.MerchantName, balance - saleValue); + } + else { + Metrics.IncrementFailedSales(cfg.MerchantId); + } + + // Auto deposit? + Decimal newBalance = await Repository.GetBalance(cfg.MerchantId); + this.Metrics.SetBalance(cfg.MerchantId, newBalance); + if (newBalance < cfg.DepositThreshold) + { + await ApiClient.SendDeposit(cfg, this.CurrentServiceToken, cfg.DepositAmount,cancellationToken); + await Repository.UpdateBalance(cfg.MerchantId, cfg.MerchantName, newBalance + cfg.DepositAmount); + newBalance = await Repository.GetBalance(cfg.MerchantId); + this.Metrics.SetBalance(cfg.MerchantId, newBalance); + } + } + + private async Task DoReconciliation(String clientId, String clientSecret, MerchantConfig cfg, CancellationToken cancellationToken) + { + var tokenResult = await this.ApiClient.GetToken(clientId, clientSecret, cfg, cancellationToken); + if (tokenResult.IsFailed) + return; + + var totals = await Repository.GetTotals(cfg.MerchantId); + await ApiClient.SendReconciliation(cfg, tokenResult.Data, totals, cancellationToken); + + // Clear totals + await this.Repository.UpdateLastEndOfDay(cfg.MerchantId, cfg.MerchantName, DateTime.Now); + await Repository.ClearTotals(cfg.MerchantId); + + this.Metrics.SetLastEndOfDay(cfg.MerchantId); + } +} \ No newline at end of file diff --git a/TransactionProcessing.MerchantPos/Runtime/MerchantRuntimeFactory.cs b/TransactionProcessing.MerchantPos/Runtime/MerchantRuntimeFactory.cs new file mode 100644 index 0000000..9d010a9 --- /dev/null +++ b/TransactionProcessing.MerchantPos/Runtime/MerchantRuntimeFactory.cs @@ -0,0 +1,26 @@ +namespace TransactionProcessing.MerchantPos.Runtime; + +public interface IMerchantRuntimeFactory +{ + MerchantRuntime Create(MerchantConfig config); +} + +public class MerchantRuntimeFactory : IMerchantRuntimeFactory +{ + private readonly IServiceProvider _serviceProvider; + + public MerchantRuntimeFactory(IServiceProvider serviceProvider) + { + this._serviceProvider = serviceProvider; + } + + public MerchantRuntime Create(MerchantConfig config) + { + // Each merchant instance gets a fresh DI scope + var scope = this._serviceProvider.CreateScope(); + + return ActivatorUtilities.CreateInstance( + scope.ServiceProvider + ); + } +} \ No newline at end of file diff --git a/TransactionProcessing.MerchantPos/Runtime/Product.cs b/TransactionProcessing.MerchantPos/Runtime/Product.cs new file mode 100644 index 0000000..c7387ed --- /dev/null +++ b/TransactionProcessing.MerchantPos/Runtime/Product.cs @@ -0,0 +1,14 @@ +using TransactionProcessorACL.DataTransferObjects.Responses; + +namespace TransactionProcessing.MerchantPos.Runtime; + +public class Product +{ + public string Name { get; set; } + public Guid OperatorId { get; set; } + public Guid ProductId { get; set; } + public Guid ContractId { get; set; } + public decimal Value { get; set; } + public ProductType ProductType { get; set; } + public ProductSubType ProductSubType { get; set; } +} \ No newline at end of file diff --git a/TransactionProcessing.MerchantPos/Runtime/ResultExtensions.cs b/TransactionProcessing.MerchantPos/Runtime/ResultExtensions.cs new file mode 100644 index 0000000..f0157cb --- /dev/null +++ b/TransactionProcessing.MerchantPos/Runtime/ResultExtensions.cs @@ -0,0 +1,14 @@ +using SimpleResults; + +namespace TransactionProcessing.MerchantPos.Runtime; + +public static class ResultExtensions +{ + public static Result FailureExtended(string message, Exception exception) + { + return Result.Failure(message, + new List{ + exception.Message + }); + } +} \ No newline at end of file diff --git a/TransactionProcessing.MerchantPos/Runtime/SaleResponse.cs b/TransactionProcessing.MerchantPos/Runtime/SaleResponse.cs new file mode 100644 index 0000000..d53e37e --- /dev/null +++ b/TransactionProcessing.MerchantPos/Runtime/SaleResponse.cs @@ -0,0 +1,3 @@ +namespace TransactionProcessing.MerchantPos.Runtime; + +public record SaleResponse(bool Authorised); \ No newline at end of file diff --git a/TransactionProcessing.MerchantPos/Runtime/WorkerSettings.cs b/TransactionProcessing.MerchantPos/Runtime/WorkerSettings.cs new file mode 100644 index 0000000..a9ac7ab --- /dev/null +++ b/TransactionProcessing.MerchantPos/Runtime/WorkerSettings.cs @@ -0,0 +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 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 new file mode 100644 index 0000000..e4fdd90 --- /dev/null +++ b/TransactionProcessing.MerchantPos/TransactionProcessing.MerchantPos.csproj @@ -0,0 +1,23 @@ + + + net10.0 + enable + enable + + + + + + + + + + + + + + + Always + + + diff --git a/TransactionProcessing.MerchantPos/TransactionProcessing.MerchantPos.sln b/TransactionProcessing.MerchantPos/TransactionProcessing.MerchantPos.sln new file mode 100644 index 0000000..2540d3a --- /dev/null +++ b/TransactionProcessing.MerchantPos/TransactionProcessing.MerchantPos.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 18 +VisualStudioVersion = 18.0.11205.157 d18.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TransactionProcessing.MerchantPos", "TransactionProcessing.MerchantPos.csproj", "{5D533FC5-47AA-5143-BFF5-C9A17807AA2D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {5D533FC5-47AA-5143-BFF5-C9A17807AA2D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5D533FC5-47AA-5143-BFF5-C9A17807AA2D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5D533FC5-47AA-5143-BFF5-C9A17807AA2D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5D533FC5-47AA-5143-BFF5-C9A17807AA2D}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {2B17C82C-B337-4F6B-B2C1-9BD86B61AD51} + EndGlobalSection +EndGlobal diff --git a/TransactionProcessing.MerchantPos/WorkerHost.cs b/TransactionProcessing.MerchantPos/WorkerHost.cs new file mode 100644 index 0000000..a089fa0 --- /dev/null +++ b/TransactionProcessing.MerchantPos/WorkerHost.cs @@ -0,0 +1,37 @@ +using Shared.Logger; +using TransactionProcessing.MerchantPos.Runtime; + +public class WorkerHost : BackgroundService +{ + private readonly IServiceProvider ServiceProvider; + private readonly WorkerSettings Settings; + + public WorkerHost(IServiceProvider serviceProvider, WorkerSettings settings) + { + this.ServiceProvider = serviceProvider; + Settings = settings; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + Logger.LogInformation($"WorkerHost starting; Merchant count: {Settings.Merchants.Count}"); + + List tasks = new List(); + foreach (MerchantConfig m in Settings.Merchants) + { + tasks.Add(StartMerchantWorker((this.Settings.ServiceClientId, this.Settings.ServiceClientSecret), (this.Settings.ClientId, this.Settings.ClientSecret), + m, stoppingToken)); + } + + await Task.WhenAll(tasks); + } + + private async Task StartMerchantWorker((String clientId, String clientSecret) serviceClient, (String clientId, String clientSecret) posClient, MerchantConfig merchant, CancellationToken token) + { + MerchantRuntime runtime = this.ServiceProvider + .GetRequiredService() + .Create(merchant); + + _ = 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 new file mode 100644 index 0000000..fbe56fe --- /dev/null +++ b/TransactionProcessing.MerchantPos/appsettings.json @@ -0,0 +1,38 @@ +{ + "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" + // } + //] + }, + + "ConnectionStrings": { + }, + + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} + diff --git a/TransactionProcessing.MerchantPos/appsettings.staging.json b/TransactionProcessing.MerchantPos/appsettings.staging.json new file mode 100644 index 0000000..abe7516 --- /dev/null +++ b/TransactionProcessing.MerchantPos/appsettings.staging.json @@ -0,0 +1,72 @@ +{ + "WorkerSettings": { + "ClientId": "mobileAppClient", + "ClientSecret": "d192cbc46d834d0da90e8a9d50ded543", + "ServiceClientId": "serviceClient", + "ServiceClientSecret": "d192cbc46d834d0da90e8a9d50ded543", + "Merchants": [ + { + "EstateId": "435613ac-a468-47a3-ac4f-649d89764c22", + "MerchantId": "ab1c99fb-1c6c-4694-9a32-b71be5d1da33", + "MerchantName": "Staging Merchant 1", + "Enabled": true, + "ApplicationVersion": "1.0.5", + "DeviceIdentifier": "stagingmerchant1device", + "Username": "merchantuser@stagingmerchant1.co.uk", + "Password": "123456", + "SaleIntervalSeconds": 30, + "FailureInjectionProbability": 0.05, + "DepositThreshold": 100, + "DepositAmount": 500, + "ClosingTime": "23:50", + "OpeningTime": "08:00" + } //, + //{ + // "MerchantId": "75DECC1D-9EFA-4844-9E58-8DDACB2CF054", + // "MerchantName": "MerchantB", + // "Enabled": false, + // "Username": "merchantuser2", + // "Password": "password2", + // "SaleIntervalSeconds": 45, + // "FailureInjectionProbability": 0.02, + // "DepositThreshold": 200, + // "DepositAmount": 1000, + // "ReconciliationTime": "23:45", + // "OpeningTime": "09:00", + // "Products": [ + // { + // "ProductId": 101, + // "Name": "Airtime Voucher", + // "Operator": "1F364B40-2E9D-44A2-BE1D-BBF563F2A24C", + // "Price": 10.00 + // }, + // { + // "ProductId": 102, + // "Name": "Electricity Token", + // "Operator": "6E668B37-8E31-4FA6-8334-D28A82F84E6F", + // "Price": 50.00 + // }, + // { + // "ProductId": 103, + // "Name": "Data Bundle", + // "Operator": "845278D8-B72E-4A71-ABCA-D63CF81E7406", + // "Price": 20.00 + // } + // ] + //} + ] + }, + + "ConnectionStrings": { + "MerchantDb": "Data Source=merchant.db" + }, + + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} + diff --git a/TransactionProcessing.MerchantPos/nlog.config b/TransactionProcessing.MerchantPos/nlog.config new file mode 100644 index 0000000..d513ca5 --- /dev/null +++ b/TransactionProcessing.MerchantPos/nlog.config @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/TransactionProcessor.SystemSetupTool/EstateSetupFunctions.cs b/TransactionProcessor.SystemSetupTool/EstateSetupFunctions.cs index 0c7f507..143be8e 100644 --- a/TransactionProcessor.SystemSetupTool/EstateSetupFunctions.cs +++ b/TransactionProcessor.SystemSetupTool/EstateSetupFunctions.cs @@ -475,11 +475,12 @@ private async Task UpdateMerchant(Merchant merchant, private async Task CreateMerchants(CancellationToken cancellationToken) { var getMerchantsResult = await this.GetMerchants(cancellationToken); - if (getMerchantsResult.IsFailed) + if (getMerchantsResult.IsFailed && getMerchantsResult.Status != ResultStatus.NotFound) return ResultHelpers.CreateFailure(getMerchantsResult); + var merchants = getMerchantsResult.Data == null ? new List() : getMerchantsResult.Data; foreach (Merchant merchant in this.EstateConfig.Merchants) { - MerchantResponse existingMerchant = getMerchantsResult.Data.SingleOrDefault(m => m.MerchantName == merchant.Name); + MerchantResponse existingMerchant = merchants.SingleOrDefault(m => m.MerchantName == merchant.Name); if (existingMerchant == null) { var createMerchantResult = await this.CreateMerchant(merchant, cancellationToken); if (createMerchantResult.IsFailed) diff --git a/TransactionProcessor.SystemSetupTool/Program.cs b/TransactionProcessor.SystemSetupTool/Program.cs index 3a5057f..2dde0b1 100644 --- a/TransactionProcessor.SystemSetupTool/Program.cs +++ b/TransactionProcessor.SystemSetupTool/Program.cs @@ -70,7 +70,7 @@ static async Task Main(string[] args) { Mode setupMode = Mode.EstateSetup; - String configFileName = "setupconfig.staging.json"; + String configFileName = "setupconfig.json"; IdentityServerConfiguration identityServerConfiguration = await Program.GetIdentityServerConfig(cancellationToken); IdentityServerFunctions identityServerFunctions = new(Program.SecurityServiceClient, identityServerConfiguration); diff --git a/TransactionProcessor.SystemSetupTool/setupconfig.json b/TransactionProcessor.SystemSetupTool/setupconfig.json index dc77553..ccc0f77 100644 --- a/TransactionProcessor.SystemSetupTool/setupconfig.json +++ b/TransactionProcessor.SystemSetupTool/setupconfig.json @@ -50,6 +50,21 @@ "name": "Safaricom", "require_custom_merchant_number": false, "require_custom_terminal_number": false + }, + { + "name": "Voucher", + "require_custom_merchant_number": false, + "require_custom_terminal_number": false + }, + { + "name": "PataPawa PostPay", + "require_custom_merchant_number": false, + "require_custom_terminal_number": false + }, + { + "name": "PataPawa PrePay", + "require_custom_merchant_number": false, + "require_custom_terminal_number": false } ], "contracts": [ @@ -97,6 +112,76 @@ ] } ] + }, + { + "operator_name": "Voucher", + "description": "Healthcare Centre 1 Contract", + "products": [ + { + "display_text": "10 KES", + "product_name": "10 KES Voucher", + "value": 10.00, + "transaction_fees": [ + { + "calculation_type": 0, + "description": "Merchant Commission", + "value": 0.3, + "fee_type": 0 + } + ] + }, + { + "display_text": "Custom", + "product_name": "Custom", + "value": null, + "transaction_fees": [ + { + "calculation_type": 0, + "description": "Merchant Commission", + "value": 0.3, + "fee_type": 0 + } + ] + } + ] + }, + { + "operator_name": "PataPawa PostPay", + "description": "PataPawa PostPay Contract", + "products": [ + { + "display_text": "Bill Pay (Post)", + "product_name": "Post Pay Bill Pay", + "value": null, + "transaction_fees": [ + { + "calculation_type": 0, + "description": "Merchant Commission", + "value": 0.95, + "fee_type": 0 + } + ] + } + ] + }, + { + "operator_name": "PataPawa PrePay", + "description": "PataPawa prePay Contract", + "products": [ + { + "display_text": "Bill Pay (Pre)", + "product_name": "Pre Pay Bill Pay", + "value": null, + "transaction_fees": [ + { + "calculation_type": 0, + "description": "Merchant Commission", + "value": 0.95, + "fee_type": 0 + } + ] + } + ] } ] } diff --git a/docker-compose-master.yml b/docker-compose-master.yml index a54f1fc..62f17b5 100644 --- a/docker-compose-master.yml +++ b/docker-compose-master.yml @@ -85,9 +85,9 @@ services: - AppSettings:ClientSecret=d192cbc46d834d0da90e8a9d50ded543 - OperatorConfiguration:Safaricom:Url=http://testhosts:9000/api/safaricom - OperatorConfiguration:PataPawaPrePay:Url=http://testhosts:9000/api/patapawaprepay - - OperatorConfiguration:PataPawaPrePay:ApiLogonRequired=false + - OperatorConfiguration:PataPawaPrePay:ApiLogonRequired=true - OperatorConfiguration:PataPawaPostPay:Url=http://testhosts:9000/PataPawaPostPayService/basichttp - - OperatorConfiguration:PataPawaPostPay:ApiLogonRequired=false + - OperatorConfiguration:PataPawaPostPay:ApiLogonRequired=true restart: on-failure depends_on: - eventstore