diff --git a/Directory.Packages.props b/Directory.Packages.props
index a8143c6..c4ddb3e 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -10,6 +10,7 @@
+
diff --git a/TransactionProcessorACL.BusinessLogic/Common/AuditPipelineBehavior.cs b/TransactionProcessorACL.BusinessLogic/Common/AuditPipelineBehavior.cs
new file mode 100644
index 0000000..87084bd
--- /dev/null
+++ b/TransactionProcessorACL.BusinessLogic/Common/AuditPipelineBehavior.cs
@@ -0,0 +1,72 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using MediatR;
+using Microsoft.AspNetCore.Http;
+
+namespace TransactionProcessorACL.Common;
+
+public sealed class AuditPipelineBehavior : IPipelineBehavior
+{
+ private readonly IHttpContextAccessor _httpContextAccessor;
+ private readonly IRequestAuditRecorder _requestAuditRecorder;
+
+ public AuditPipelineBehavior(IHttpContextAccessor httpContextAccessor,
+ IRequestAuditRecorder requestAuditRecorder)
+ {
+ _httpContextAccessor = httpContextAccessor;
+ _requestAuditRecorder = requestAuditRecorder;
+ }
+
+ public async Task Handle(TRequest request,
+ RequestHandlerDelegate next,
+ CancellationToken cancellationToken)
+ {
+ HttpContext? httpContext = _httpContextAccessor.HttpContext;
+ if (httpContext is null)
+ {
+ return await next().ConfigureAwait(false);
+ }
+
+ Boolean failed = false;
+ String? errorMessage = null;
+
+ try
+ {
+ TResponse? response = await next(cancellationToken).ConfigureAwait(false);
+ return response;
+ }
+ catch (Exception ex)
+ {
+ failed = true;
+ errorMessage = ex.Message;
+ throw;
+ }
+ finally
+ {
+ await RecordAuditAsync(httpContext, request, failed, errorMessage, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ private async Task RecordAuditAsync(HttpContext httpContext,
+ TRequest request,
+ Boolean failed,
+ String? errorMessage,
+ CancellationToken cancellationToken)
+ {
+ RequestAuditEvent auditEvent = RequestAuditContextBuilder.Build(
+ httpContext,
+ request,
+ failed ? "Failed" : "Succeeded",
+ errorMessage);
+
+ try
+ {
+ await _requestAuditRecorder.RecordAsync(auditEvent, cancellationToken).ConfigureAwait(false);
+ }
+ catch
+ {
+ // Audit must not interfere with the request path.
+ }
+ }
+}
diff --git a/TransactionProcessorACL.BusinessLogic/Common/IRequestAuditRecorder.cs b/TransactionProcessorACL.BusinessLogic/Common/IRequestAuditRecorder.cs
new file mode 100644
index 0000000..dfea358
--- /dev/null
+++ b/TransactionProcessorACL.BusinessLogic/Common/IRequestAuditRecorder.cs
@@ -0,0 +1,9 @@
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace TransactionProcessorACL.Common;
+
+public interface IRequestAuditRecorder
+{
+ Task RecordAsync(RequestAuditEvent auditEvent, CancellationToken cancellationToken);
+}
diff --git a/TransactionProcessorACL.BusinessLogic/Common/LoggingRequestAuditRecorder.cs b/TransactionProcessorACL.BusinessLogic/Common/LoggingRequestAuditRecorder.cs
new file mode 100644
index 0000000..0522106
--- /dev/null
+++ b/TransactionProcessorACL.BusinessLogic/Common/LoggingRequestAuditRecorder.cs
@@ -0,0 +1,83 @@
+using KurrentDB.Client;
+using Microsoft.AspNetCore.Http;
+using Shared.Logger;
+using System;
+using System.Collections.Generic;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace TransactionProcessorACL.Common;
+
+public sealed class LoggingRequestAuditRecorder : IRequestAuditRecorder
+{
+ public Task RecordAsync(RequestAuditEvent auditEvent, CancellationToken cancellationToken)
+ {
+ string payload = JsonSerializer.Serialize(auditEvent);
+ Logger.LogWarning($"AUDIT {payload}");
+ return Task.CompletedTask;
+ }
+
+ public class KurrentDbRequestAuditRecorder : IRequestAuditRecorder
+ {
+ private readonly KurrentDBClient KurrentDbClient;
+ private readonly IHttpContextAccessor _httpContextAccessor;
+ private readonly RequestAuditStreamOptions _streamOptions;
+
+ public KurrentDbRequestAuditRecorder(KurrentDBClient KurrentDBClient,
+ IHttpContextAccessor httpContextAccessor,
+ RequestAuditStreamOptions streamOptions) {
+ this.KurrentDbClient = KurrentDBClient;
+ _httpContextAccessor = httpContextAccessor;
+ _streamOptions = streamOptions;
+ }
+
+ public async Task RecordAsync(RequestAuditEvent auditEvent, CancellationToken cancellationToken) {
+ String requestId = GetRequestId(auditEvent);
+ String streamName = $"RequestAudit-{requestId}";
+
+ StreamMetadata streamMetadata = new(
+ maxCount: null,
+ maxAge: _streamOptions.RequestAuditStreamLifetime,
+ truncateBefore: null,
+ cacheControl: null,
+ acl: null,
+ customMetadata: null);
+
+ await this.KurrentDbClient.SetStreamMetadataAsync(
+ streamName,
+ StreamState.Any,
+ streamMetadata,
+ cancellationToken: cancellationToken);
+
+ List events = [CreateEventData(Guid.NewGuid(), "RequestAuditEvent", JsonSerializer.Serialize(auditEvent), "")];
+
+ await this.KurrentDbClient.AppendToStreamAsync(streamName, StreamState.Any, events, cancellationToken: cancellationToken);
+ }
+
+ private String GetRequestId(RequestAuditEvent auditEvent)
+ {
+ HttpContext? httpContext = _httpContextAccessor.HttpContext;
+ if (httpContext is not null)
+ {
+ return RequestIdProvider.GetOrCreateRequestId(httpContext);
+ }
+
+ return auditEvent.Context.RequestId;
+ }
+
+ private static EventData CreateEventData(Guid eventId,
+ String eventType,
+ String data,
+ String metadata)
+ {
+ Byte[] eventData = Encoding.Default.GetBytes(data);
+ Byte[] eventMetadata = Encoding.Default.GetBytes(metadata);
+
+ EventData @event = new(Uuid.FromGuid(eventId), eventType, eventData, eventMetadata);
+
+ return @event;
+ }
+ }
+}
diff --git a/TransactionProcessorACL.BusinessLogic/Common/RequestAuditContext.cs b/TransactionProcessorACL.BusinessLogic/Common/RequestAuditContext.cs
new file mode 100644
index 0000000..40d3b14
--- /dev/null
+++ b/TransactionProcessorACL.BusinessLogic/Common/RequestAuditContext.cs
@@ -0,0 +1,18 @@
+using System;
+using System.Collections.Generic;
+
+namespace TransactionProcessorACL.Common;
+
+public sealed record RequestAuditContext(
+ string RequestId,
+ string TraceId,
+ DateTimeOffset TimestampUtc,
+ string Method,
+ string Route,
+ string SourceIp,
+ string UserAgent,
+ Guid? EstateId,
+ Guid? MerchantId,
+ string TransactionType,
+ string? TransactionNumber,
+ IReadOnlyDictionary BusinessContext);
diff --git a/TransactionProcessorACL.BusinessLogic/Common/RequestAuditContextAccessor.cs b/TransactionProcessorACL.BusinessLogic/Common/RequestAuditContextAccessor.cs
new file mode 100644
index 0000000..7014991
--- /dev/null
+++ b/TransactionProcessorACL.BusinessLogic/Common/RequestAuditContextAccessor.cs
@@ -0,0 +1,15 @@
+using System;
+using System.Threading;
+
+namespace TransactionProcessorACL.Common;
+
+public sealed class RequestAuditContextAccessor
+{
+ private static readonly AsyncLocal CurrentContext = new();
+
+ public RequestAuditContext? Current
+ {
+ get => CurrentContext.Value;
+ set => CurrentContext.Value = value;
+ }
+}
diff --git a/TransactionProcessorACL.BusinessLogic/Common/RequestAuditContextBuilder.cs b/TransactionProcessorACL.BusinessLogic/Common/RequestAuditContextBuilder.cs
new file mode 100644
index 0000000..c98c9f9
--- /dev/null
+++ b/TransactionProcessorACL.BusinessLogic/Common/RequestAuditContextBuilder.cs
@@ -0,0 +1,200 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Reflection;
+using System.Security.Claims;
+using Microsoft.AspNetCore.Http;
+using System.Text;
+using TransactionProcessorACL.BusinessLogic.Requests;
+
+namespace TransactionProcessorACL.Common;
+
+public static class RequestAuditContextBuilder
+{
+ public static RequestAuditEvent Build(HttpContext httpContext, TRequest request, String outcome, String? errorMessage)
+ {
+ IReadOnlyDictionary businessContext = BuildBusinessContext(request);
+ (Guid? estateId, Guid? merchantId) = ExtractClaims(httpContext.User);
+ String requestType = request?.GetType().Name ?? typeof(TRequest).Name;
+
+ RequestAuditContext context = new(
+ RequestId: RequestIdProvider.GetOrCreateRequestId(httpContext),
+ TraceId: httpContext.TraceIdentifier,
+ TimestampUtc: DateTimeOffset.UtcNow,
+ Method: httpContext.Request.Method,
+ Route: httpContext.Request.Path.Value ?? String.Empty,
+ SourceIp: httpContext.Connection.RemoteIpAddress?.ToString() ?? String.Empty,
+ UserAgent: httpContext.Request.Headers["User-Agent"].ToString(),
+ EstateId: estateId,
+ MerchantId: merchantId,
+ TransactionType: requestType,
+ TransactionNumber: TryGetTransactionNumber(request),
+ BusinessContext: businessContext);
+
+ return new RequestAuditEvent(context, requestType, outcome, errorMessage, businessContext);
+ }
+
+ private static (Guid? EstateId, Guid? MerchantId) ExtractClaims(ClaimsPrincipal principal)
+ {
+ return (TryGetGuidClaim(principal, "estateId"), TryGetGuidClaim(principal, "merchantId"));
+ }
+
+ private static Guid? TryGetGuidClaim(ClaimsPrincipal principal, String claimType)
+ {
+ Claim? claim = principal.Claims.FirstOrDefault(c => String.Equals(c.Type, claimType, StringComparison.OrdinalIgnoreCase));
+ return Guid.TryParse(claim?.Value, out Guid value) ? value : null;
+ }
+
+ private static IReadOnlyDictionary BuildBusinessContext(TRequest request)
+ {
+ Dictionary businessContext = new(StringComparer.OrdinalIgnoreCase);
+ if (request is null)
+ {
+ return businessContext;
+ }
+
+ foreach (PropertyInfo property in request.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public))
+ {
+ if (!property.CanRead || property.GetIndexParameters().Length > 0)
+ {
+ continue;
+ }
+
+ if (LooksSensitive(property.Name))
+ {
+ continue;
+ }
+
+ object? value = property.GetValue(request);
+ if (value is null)
+ {
+ continue;
+ }
+
+ if (TryAddDictionaryBusinessContext(businessContext, value))
+ {
+ continue;
+ }
+
+ if (TryFormatValue(value, out String formattedValue))
+ {
+ businessContext[GetBusinessContextKey(property.Name, value)] = formattedValue;
+ }
+ }
+
+ return businessContext;
+ }
+
+ private static String GetBusinessContextKey(String propertyName, object value)
+ {
+ String key = ToSnakeCase(propertyName);
+ return value is DateTime or DateTimeOffset ? $"{key}_utc" : key;
+ }
+
+ private static bool TryAddDictionaryBusinessContext(IDictionary businessContext, object value)
+ {
+ if (value is not IEnumerable> entries)
+ {
+ return false;
+ }
+
+ foreach (KeyValuePair item in entries)
+ {
+ if (LooksSensitive(item.Key))
+ {
+ continue;
+ }
+
+ businessContext[item.Key] = item.Value;
+ }
+
+ return true;
+ }
+
+ private static String? TryGetTransactionNumber(TRequest request)
+ {
+ if (request is null)
+ {
+ return null;
+ }
+
+ PropertyInfo? transactionNumberProperty = request.GetType().GetProperty(
+ "TransactionNumber",
+ BindingFlags.Instance | BindingFlags.Public | BindingFlags.IgnoreCase);
+
+ if (transactionNumberProperty is null || !transactionNumberProperty.CanRead)
+ {
+ return null;
+ }
+
+ object? value = transactionNumberProperty.GetValue(request);
+ return value switch
+ {
+ null => null,
+ String stringValue => stringValue,
+ _ => value.ToString()
+ };
+ }
+
+ private static bool LooksSensitive(String key)
+ {
+ return key.Contains("password", StringComparison.OrdinalIgnoreCase)
+ || key.Contains("token", StringComparison.OrdinalIgnoreCase)
+ || key.Contains("secret", StringComparison.OrdinalIgnoreCase)
+ || key.Contains("authorization", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool TryFormatValue(object value, out String formattedValue)
+ {
+ switch (value)
+ {
+ case String stringValue:
+ formattedValue = stringValue;
+ return true;
+ case Guid guidValue:
+ formattedValue = guidValue.ToString();
+ return true;
+ case DateTime dateTimeValue:
+ formattedValue = dateTimeValue.ToUniversalTime().ToString("O");
+ return true;
+ case DateTimeOffset dateTimeOffsetValue:
+ formattedValue = dateTimeOffsetValue.ToUniversalTime().ToString("O");
+ return true;
+ case Enum enumValue:
+ formattedValue = enumValue.ToString();
+ return true;
+ case Boolean booleanValue:
+ formattedValue = booleanValue.ToString();
+ return true;
+ case IFormattable formattableValue:
+ formattedValue = formattableValue.ToString(null, CultureInfo.InvariantCulture) ?? String.Empty;
+ return true;
+ default:
+ formattedValue = String.Empty;
+ return false;
+ }
+ }
+
+ private static String ToSnakeCase(String value)
+ {
+ if (String.IsNullOrWhiteSpace(value))
+ {
+ return value;
+ }
+
+ StringBuilder builder = new();
+ for (Int32 index = 0; index < value.Length; index++)
+ {
+ Char character = value[index];
+ if (Char.IsUpper(character) && builder.Length > 0)
+ {
+ builder.Append('_');
+ }
+
+ builder.Append(Char.ToLowerInvariant(character));
+ }
+
+ return builder.ToString();
+ }
+}
diff --git a/TransactionProcessorACL.BusinessLogic/Common/RequestAuditEvent.cs b/TransactionProcessorACL.BusinessLogic/Common/RequestAuditEvent.cs
new file mode 100644
index 0000000..2c5e8f4
--- /dev/null
+++ b/TransactionProcessorACL.BusinessLogic/Common/RequestAuditEvent.cs
@@ -0,0 +1,11 @@
+using System;
+using System.Collections.Generic;
+
+namespace TransactionProcessorACL.Common;
+
+public sealed record RequestAuditEvent(
+ RequestAuditContext Context,
+ String RequestType,
+ String Outcome,
+ String? ErrorMessage,
+ IReadOnlyDictionary BusinessContext);
diff --git a/TransactionProcessorACL.BusinessLogic/Common/RequestAuditStreamOptions.cs b/TransactionProcessorACL.BusinessLogic/Common/RequestAuditStreamOptions.cs
new file mode 100644
index 0000000..a5559e8
--- /dev/null
+++ b/TransactionProcessorACL.BusinessLogic/Common/RequestAuditStreamOptions.cs
@@ -0,0 +1,10 @@
+using System;
+
+namespace TransactionProcessorACL.Common;
+
+public sealed record RequestAuditStreamOptions
+{
+ public Int32 RequestAuditStreamLifetimeDays { get; init; } = 7;
+
+ public TimeSpan RequestAuditStreamLifetime => TimeSpan.FromDays(RequestAuditStreamLifetimeDays);
+}
diff --git a/TransactionProcessorACL.BusinessLogic/Common/RequestIdProvider.cs b/TransactionProcessorACL.BusinessLogic/Common/RequestIdProvider.cs
new file mode 100644
index 0000000..89288e7
--- /dev/null
+++ b/TransactionProcessorACL.BusinessLogic/Common/RequestIdProvider.cs
@@ -0,0 +1,20 @@
+using System;
+using Microsoft.AspNetCore.Http;
+
+namespace TransactionProcessorACL.Common;
+
+public static class RequestIdProvider
+{
+ private static readonly object RequestIdItemKey = new();
+
+ public static string GetOrCreateRequestId(HttpContext context)
+ {
+ if (context.Items.TryGetValue(RequestIdItemKey, out object? value) && value is string requestId && !string.IsNullOrWhiteSpace(requestId)) {
+ return requestId;
+ }
+
+ string generatedRequestId = Guid.NewGuid().ToString("D");
+ context.Items[RequestIdItemKey] = generatedRequestId;
+ return generatedRequestId;
+ }
+}
diff --git a/TransactionProcessorACL.BusinessLogic/RequestHandlers/ProcessLogonTransactionRequestHandler.cs b/TransactionProcessorACL.BusinessLogic/RequestHandlers/ProcessLogonTransactionRequestHandler.cs
index 0c018da..a5337ed 100644
--- a/TransactionProcessorACL.BusinessLogic/RequestHandlers/ProcessLogonTransactionRequestHandler.cs
+++ b/TransactionProcessorACL.BusinessLogic/RequestHandlers/ProcessLogonTransactionRequestHandler.cs
@@ -1,4 +1,4 @@
-using SimpleResults;
+using SimpleResults;
namespace TransactionProcessorACL.BusinessLogic.RequestHandlers
{
@@ -76,4 +76,4 @@ public async Task> Handle(TransactionComm
#endregion
}
-}
\ No newline at end of file
+}
diff --git a/TransactionProcessorACL.BusinessLogic/Requests/TransactionCommands.cs b/TransactionProcessorACL.BusinessLogic/Requests/TransactionCommands.cs
index 6ac5238..8b4ecff 100644
--- a/TransactionProcessorACL.BusinessLogic/Requests/TransactionCommands.cs
+++ b/TransactionProcessorACL.BusinessLogic/Requests/TransactionCommands.cs
@@ -36,4 +36,4 @@ public record ProcessReconciliationCommand(Guid EstateId,
Int32 TransactionCount,
Decimal TransactionValue)
: IRequest>;
-}
\ No newline at end of file
+}
diff --git a/TransactionProcessorACL.BusinessLogic/TransactionProcessorACL.BusinessLogic.csproj b/TransactionProcessorACL.BusinessLogic/TransactionProcessorACL.BusinessLogic.csproj
index fd5b0a9..372b7a8 100644
--- a/TransactionProcessorACL.BusinessLogic/TransactionProcessorACL.BusinessLogic.csproj
+++ b/TransactionProcessorACL.BusinessLogic/TransactionProcessorACL.BusinessLogic.csproj
@@ -6,12 +6,17 @@
+
+
+
+
+
diff --git a/TransactionProcessorACL.Testing/TestData.cs b/TransactionProcessorACL.Testing/TestData.cs
index 79fcf3b..1b8b855 100644
--- a/TransactionProcessorACL.Testing/TestData.cs
+++ b/TransactionProcessorACL.Testing/TestData.cs
@@ -7,6 +7,7 @@ namespace TransactionProcessorACL.Testing
using Models;
using TransactionProcessor.DataTransferObjects;
using TransactionProcessorACL.BusinessLogic.Requests;
+ using TransactionProcessorACL.Common;
using TransactionProcessorACL.DataTransferObjects;
using GetVoucherResponse = TransactionProcessor.DataTransferObjects.GetVoucherResponse;
using RedeemVoucherResponse = TransactionProcessor.DataTransferObjects.RedeemVoucherResponse;
@@ -53,6 +54,20 @@ public class TestData
///
public static Guid MerchantId = Guid.Parse("1C8354B7-B97A-46EA-9AD1-C43F33F7E3C3");
+ public static RequestAuditContext RequestAuditContext = new RequestAuditContext(
+ RequestId: "req-123",
+ TraceId: "trace-123",
+ TimestampUtc: DateTimeOffset.UtcNow,
+ Method: "POST",
+ Route: "/api/saletransactions",
+ SourceIp: "203.0.113.9",
+ UserAgent: "TestClient/1.0",
+ EstateId: EstateId,
+ MerchantId: MerchantId,
+ TransactionType: "SALE",
+ TransactionNumber: TransactionNumber,
+ BusinessContext: new Dictionary());
+
public static Int32 ScheduleYear = 2026;
///
@@ -178,7 +193,7 @@ public class TestData
["AppSettings:ClientSecret"] = "secret",
["SecurityConfiguration:Authority"] = "https://127.0.0.1",
["SecurityConfiguration:ApiName"] = "ApiName",
- ["EventStoreSettings:ConnectionString"] = "https://127.0.0.1:2113"
+ ["EventStoreSettings:ConnectionString"] = "esdb://127.0.0.1:2113"
};
public static IReadOnlyDictionary DefaultAppSettingsSkipVersionCheck =>
@@ -362,4 +377,4 @@ public static TransactionProcessor.DataTransferObjects.Responses.Merchant.Mercha
return new TransactionProcessor.DataTransferObjects.Responses.Merchant.MerchantScheduleResponse { Year = ScheduleYear, Months = new List() { new TransactionProcessor.DataTransferObjects.Responses.Merchant.MerchantScheduleMonthResponse { ClosedDays = new List { 1, 2 }, Month = 1 }, new TransactionProcessor.DataTransferObjects.Responses.Merchant.MerchantScheduleMonthResponse { ClosedDays = new List { 25, 26 }, Month = 12 } } };
}
}
-}
\ No newline at end of file
+}
diff --git a/TransactionProcessorACL.Tests/Audit/AuditPipelineBehaviorTests.cs b/TransactionProcessorACL.Tests/Audit/AuditPipelineBehaviorTests.cs
new file mode 100644
index 0000000..81f7971
--- /dev/null
+++ b/TransactionProcessorACL.Tests/Audit/AuditPipelineBehaviorTests.cs
@@ -0,0 +1,104 @@
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Security.Claims;
+using System.Threading;
+using System.Threading.Tasks;
+using MediatR;
+using Microsoft.AspNetCore.Http;
+using Shouldly;
+using SimpleResults;
+using TransactionProcessorACL.BusinessLogic.Requests;
+using TransactionProcessorACL.Common;
+using TransactionProcessorACL.Models;
+using Xunit;
+
+namespace TransactionProcessorACL.Tests.Audit;
+
+public class AuditPipelineBehaviorTests
+{
+ [Fact]
+ public async Task Handle_WhenPipelineRuns_RecordsSingleAuditEventWithBusinessContext()
+ {
+ var httpContext = new DefaultHttpContext();
+ httpContext.TraceIdentifier = "trace-123";
+ httpContext.Request.Method = HttpMethods.Post;
+ httpContext.Request.Path = "/api/saletransactions";
+ httpContext.Request.Headers["User-Agent"] = "TestClient/1.0";
+ httpContext.Connection.RemoteIpAddress = IPAddress.Parse("203.0.113.9");
+ httpContext.User = new ClaimsPrincipal(new ClaimsIdentity(new[]
+ {
+ new Claim("estateId", "1C8354B7-B97A-46EA-9AD1-C43F33F7E3C3"),
+ new Claim("merchantId", "2C8354B7-B97A-46EA-9AD1-C43F33F7E3C4"),
+ }, "Bearer"));
+
+ var accessor = new HttpContextAccessor { HttpContext = httpContext };
+ var recorder = new CapturingAuditRecorder();
+ var behavior = new AuditPipelineBehavior>(accessor, recorder);
+ var request = new TransactionCommands.ProcessSaleTransactionCommand(
+ Guid.Parse("1C8354B7-B97A-46EA-9AD1-C43F33F7E3C3"),
+ Guid.Parse("2C8354B7-B97A-46EA-9AD1-C43F33F7E3C4"),
+ new System.DateTime(2026, 6, 30, 10, 15, 0),
+ "TX-0001",
+ "device-01",
+ Guid.Parse("5C8354B7-B97A-46EA-9AD1-C43F33F7E3C7"),
+ "customer@example.com",
+ Guid.Parse("3C8354B7-B97A-46EA-9AD1-C43F33F7E3C5"),
+ Guid.Parse("4C8354B7-B97A-46EA-9AD1-C43F33F7E3C6"),
+ new Dictionary { ["amount"] = "1000.00" });
+
+ Result response = await behavior.Handle(request, cancellationToken => Task.FromResult(Result.Success(new ProcessSaleTransactionResponse())), CancellationToken.None);
+
+ response.IsSuccess.ShouldBeTrue();
+ recorder.CallCount.ShouldBe(1);
+ recorder.LastEvent.ShouldNotBeNull();
+ Guid.TryParse(recorder.LastEvent!.Context.RequestId, out _).ShouldBeTrue();
+ recorder.LastEvent.Context.EstateId.ShouldBe(Guid.Parse("1C8354B7-B97A-46EA-9AD1-C43F33F7E3C3"));
+ recorder.LastEvent.Context.MerchantId.ShouldBe(Guid.Parse("2C8354B7-B97A-46EA-9AD1-C43F33F7E3C4"));
+ recorder.LastEvent.RequestType.ShouldBe(nameof(TransactionCommands.ProcessSaleTransactionCommand));
+ recorder.LastEvent.Context.BusinessContext["amount"].ShouldBe("1000.00");
+ }
+
+ [Fact]
+ public async Task Handle_WhenVersionCheckRuns_WithClaims_RecordsEstateAndMerchantIds()
+ {
+ var httpContext = new DefaultHttpContext();
+ httpContext.TraceIdentifier = "trace-456";
+ httpContext.Request.Method = HttpMethods.Post;
+ httpContext.Request.Path = "/api/versioncheck";
+ httpContext.User = new ClaimsPrincipal(new ClaimsIdentity(new[]
+ {
+ new Claim("estateId", "1C8354B7-B97A-46EA-9AD1-C43F33F7E3C3"),
+ new Claim("merchantId", "2C8354B7-B97A-46EA-9AD1-C43F33F7E3C4"),
+ }, "Bearer"));
+
+ var accessor = new HttpContextAccessor { HttpContext = httpContext };
+ var recorder = new CapturingAuditRecorder();
+ var behavior = new AuditPipelineBehavior(accessor, recorder);
+ var request = new VersionCheckCommands.VersionCheckCommand("9.9.9");
+
+ Result response = await behavior.Handle(request, cancellationToken => Task.FromResult(Result.Success()), CancellationToken.None);
+
+ response.IsSuccess.ShouldBeTrue();
+ recorder.CallCount.ShouldBe(1);
+ recorder.LastEvent.ShouldNotBeNull();
+ recorder.LastEvent!.Context.EstateId.ShouldBe(Guid.Parse("1C8354B7-B97A-46EA-9AD1-C43F33F7E3C3"));
+ recorder.LastEvent.Context.MerchantId.ShouldBe(Guid.Parse("2C8354B7-B97A-46EA-9AD1-C43F33F7E3C4"));
+ recorder.LastEvent.RequestType.ShouldBe(nameof(VersionCheckCommands.VersionCheckCommand));
+ recorder.LastEvent.Context.BusinessContext["version_number"].ShouldBe("9.9.9");
+ }
+
+ private sealed class CapturingAuditRecorder : IRequestAuditRecorder
+ {
+ public int CallCount { get; private set; }
+
+ public RequestAuditEvent? LastEvent { get; private set; }
+
+ public Task RecordAsync(RequestAuditEvent auditEvent, CancellationToken cancellationToken)
+ {
+ CallCount++;
+ LastEvent = auditEvent;
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/TransactionProcessorACL.Tests/Audit/CorrelationHeaderDelegatingHandlerTests.cs b/TransactionProcessorACL.Tests/Audit/CorrelationHeaderDelegatingHandlerTests.cs
new file mode 100644
index 0000000..4d43653
--- /dev/null
+++ b/TransactionProcessorACL.Tests/Audit/CorrelationHeaderDelegatingHandlerTests.cs
@@ -0,0 +1,67 @@
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Shouldly;
+using TransactionProcessorACL.Common;
+using Xunit;
+
+namespace TransactionProcessorACL.Tests.Audit;
+
+public class CorrelationHeaderDelegatingHandlerTests
+{
+ [Fact]
+ public async Task SendAsync_WhenRequestContextExists_AddsRequestIdHeader()
+ {
+ var accessor = new RequestAuditContextAccessor();
+ accessor.Current = new RequestAuditContext(
+ RequestId: "req-123",
+ TraceId: "trace-123",
+ TimestampUtc: System.DateTimeOffset.UtcNow,
+ Method: "POST",
+ Route: "/api/saletransactions",
+ SourceIp: "203.0.113.9",
+ UserAgent: "TestClient/1.0",
+ EstateId: null,
+ MerchantId: null,
+ TransactionType: "SALE",
+ TransactionNumber: "TX-0001",
+ BusinessContext: new System.Collections.Generic.Dictionary());
+
+ HttpRequestMessage? capturedRequest = null;
+ var innerHandler = new CapturingHandler(request =>
+ {
+ capturedRequest = request;
+ return new HttpResponseMessage(HttpStatusCode.OK);
+ });
+
+ var handler = new CorrelationHeaderDelegatingHandler(accessor)
+ {
+ InnerHandler = innerHandler
+ };
+
+ using var invoker = new HttpMessageInvoker(handler);
+
+ HttpResponseMessage response = await invoker.SendAsync(new HttpRequestMessage(HttpMethod.Get, "https://example.test"), CancellationToken.None);
+
+ response.StatusCode.ShouldBe(HttpStatusCode.OK);
+ capturedRequest.ShouldNotBeNull();
+ capturedRequest!.Headers.Contains("X-Request-Id").ShouldBeTrue();
+ capturedRequest.Headers.GetValues("X-Request-Id").ShouldContain("req-123");
+ }
+
+ private sealed class CapturingHandler : HttpMessageHandler
+ {
+ private readonly System.Func _callback;
+
+ public CapturingHandler(System.Func callback)
+ {
+ _callback = callback;
+ }
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(_callback(request));
+ }
+ }
+}
diff --git a/TransactionProcessorACL.Tests/Audit/RequestAuditContextFactoryTests.cs b/TransactionProcessorACL.Tests/Audit/RequestAuditContextFactoryTests.cs
new file mode 100644
index 0000000..ac35a2f
--- /dev/null
+++ b/TransactionProcessorACL.Tests/Audit/RequestAuditContextFactoryTests.cs
@@ -0,0 +1,68 @@
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Security.Claims;
+using Microsoft.AspNetCore.Http;
+using Shouldly;
+using TransactionProcessorACL.DataTransferObjects;
+using TransactionProcessorACL.Common;
+using Xunit;
+
+namespace TransactionProcessorACL.Tests.Audit;
+
+public class RequestAuditContextFactoryTests
+{
+ [Fact]
+ public void CreateForTransaction_WhenRequestHasCorrelationHeader_CapturesRequestAndBusinessContext()
+ {
+ var context = new DefaultHttpContext();
+ context.TraceIdentifier = "trace-123";
+ context.Request.Method = HttpMethods.Post;
+ context.Request.Path = "/api/saletransactions";
+ context.Request.Headers["User-Agent"] = "TestClient/1.0";
+ context.Connection.RemoteIpAddress = IPAddress.Parse("203.0.113.9");
+
+ var user = new ClaimsPrincipal(new ClaimsIdentity(new[]
+ {
+ new Claim("estateId", "1C8354B7-B97A-46EA-9AD1-C43F33F7E3C3"),
+ new Claim("merchantId", "2C8354B7-B97A-46EA-9AD1-C43F33F7E3C4"),
+ }));
+
+ var request = new SaleTransactionRequestMessage
+ {
+ ApplicationVersion = "9.9.9",
+ TransactionNumber = "TX-0001",
+ DeviceIdentifier = "device-01",
+ TransactionDateTime = new System.DateTime(2026, 6, 30, 10, 15, 0),
+ CustomerEmailAddress = "customer@example.com",
+ ContractId = System.Guid.Parse("3C8354B7-B97A-46EA-9AD1-C43F33F7E3C5"),
+ ProductId = System.Guid.Parse("4C8354B7-B97A-46EA-9AD1-C43F33F7E3C6"),
+ OperatorId = System.Guid.Parse("5C8354B7-B97A-46EA-9AD1-C43F33F7E3C7"),
+ AdditionalRequestMetadata = new Dictionary
+ {
+ ["amount"] = "1000.00"
+ }
+ };
+
+ RequestAuditContext auditContext = RequestAuditContextFactory.CreateForTransaction(context, user, request, "SALE");
+ RequestAuditContext secondAuditContext = RequestAuditContextFactory.CreateForRequest(context);
+
+ Guid.TryParse(auditContext.RequestId, out _).ShouldBeTrue();
+ secondAuditContext.RequestId.ShouldBe(auditContext.RequestId);
+ auditContext.TraceId.ShouldBe("trace-123");
+ auditContext.Method.ShouldBe("POST");
+ auditContext.Route.ShouldBe("/api/saletransactions");
+ auditContext.SourceIp.ShouldBe("203.0.113.9");
+ auditContext.UserAgent.ShouldBe("TestClient/1.0");
+ auditContext.EstateId.ShouldBe(System.Guid.Parse("1C8354B7-B97A-46EA-9AD1-C43F33F7E3C3"));
+ auditContext.MerchantId.ShouldBe(System.Guid.Parse("2C8354B7-B97A-46EA-9AD1-C43F33F7E3C4"));
+ auditContext.TransactionType.ShouldBe("SALE");
+ auditContext.TransactionNumber.ShouldBe("TX-0001");
+ auditContext.BusinessContext["application_version"].ShouldBe("9.9.9");
+ auditContext.BusinessContext["customer_email_address"].ShouldBe("customer@example.com");
+ auditContext.BusinessContext["contract_id"].ToUpperInvariant().ShouldBe("3C8354B7-B97A-46EA-9AD1-C43F33F7E3C5");
+ auditContext.BusinessContext["product_id"].ToUpperInvariant().ShouldBe("4C8354B7-B97A-46EA-9AD1-C43F33F7E3C6");
+ auditContext.BusinessContext["operator_id"].ToUpperInvariant().ShouldBe("5C8354B7-B97A-46EA-9AD1-C43F33F7E3C7");
+ auditContext.BusinessContext["amount"].ShouldBe("1000.00");
+ }
+}
diff --git a/TransactionProcessorACL.Tests/Audit/RequestAuditStreamOptionsTests.cs b/TransactionProcessorACL.Tests/Audit/RequestAuditStreamOptionsTests.cs
new file mode 100644
index 0000000..fba284e
--- /dev/null
+++ b/TransactionProcessorACL.Tests/Audit/RequestAuditStreamOptionsTests.cs
@@ -0,0 +1,30 @@
+using System;
+using Shouldly;
+using TransactionProcessorACL.Common;
+using Xunit;
+
+namespace TransactionProcessorACL.Tests.Audit;
+
+public class RequestAuditStreamOptionsTests
+{
+ [Fact]
+ public void DefaultLifetime_IsSevenDays()
+ {
+ var options = new RequestAuditStreamOptions();
+
+ options.RequestAuditStreamLifetimeDays.ShouldBe(7);
+ options.RequestAuditStreamLifetime.ShouldBe(TimeSpan.FromDays(7));
+ }
+
+ [Fact]
+ public void CustomLifetime_ConvertsToTimeSpan()
+ {
+ var options = new RequestAuditStreamOptions
+ {
+ RequestAuditStreamLifetimeDays = 14
+ };
+
+ options.RequestAuditStreamLifetimeDays.ShouldBe(14);
+ options.RequestAuditStreamLifetime.ShouldBe(TimeSpan.FromDays(14));
+ }
+}
diff --git a/TransactionProcessorACL.Tests/Handlers/TransactionHandlersTests.cs b/TransactionProcessorACL.Tests/Handlers/TransactionHandlersTests.cs
new file mode 100644
index 0000000..d3c5895
--- /dev/null
+++ b/TransactionProcessorACL.Tests/Handlers/TransactionHandlersTests.cs
@@ -0,0 +1,61 @@
+using System.Collections.Generic;
+using System.Security.Claims;
+using System.Threading;
+using System.Threading.Tasks;
+using MediatR;
+using Moq;
+using Shouldly;
+using SimpleResults;
+using TransactionProcessorACL.BusinessLogic.Requests;
+using TransactionProcessorACL.DataTransferObjects;
+using TransactionProcessorACL.Handlers;
+using Xunit;
+
+namespace TransactionProcessorACL.Tests.Handlers;
+
+public class TransactionHandlersTests
+{
+ [Fact]
+ public async Task PerformSaleTransaction_PassesBusinessFieldsIntoCommand()
+ {
+ var user = new ClaimsPrincipal(new ClaimsIdentity(new[]
+ {
+ new Claim("estateId", "1C8354B7-B97A-46EA-9AD1-C43F33F7E3C3"),
+ new Claim("merchantId", "2C8354B7-B97A-46EA-9AD1-C43F33F7E3C4"),
+ }, "Bearer"));
+
+ var request = new SaleTransactionRequestMessage
+ {
+ TransactionNumber = "TX-0001",
+ DeviceIdentifier = "device-01",
+ TransactionDateTime = new System.DateTime(2026, 6, 30, 10, 15, 0),
+ CustomerEmailAddress = "customer@example.com",
+ ContractId = System.Guid.Parse("3C8354B7-B97A-46EA-9AD1-C43F33F7E3C5"),
+ ProductId = System.Guid.Parse("4C8354B7-B97A-46EA-9AD1-C43F33F7E3C6"),
+ OperatorId = System.Guid.Parse("5C8354B7-B97A-46EA-9AD1-C43F33F7E3C7"),
+ AdditionalRequestMetadata = new Dictionary
+ {
+ ["amount"] = "1000.00"
+ }
+ };
+
+ TransactionCommands.ProcessSaleTransactionCommand? capturedCommand = null;
+
+ var mediator = new Mock(MockBehavior.Strict);
+ mediator
+ .Setup(m => m.Send(It.IsAny(), It.IsAny()))
+ .Callback