Example: Audit History Tracking
Overview
This example demonstrates the comprehensive audit history tracking system implemented in the AR Payment Reversal dashboard. The system maintains a complete audit trail of all payment reversal activities, providing compliance, troubleshooting capabilities, and business intelligence insights. Every action, from queue operations to SYSPRO postings, is tracked with full context and metadata.
Business Requirements
The audit system addresses critical compliance and operational needs:
- Regulatory Compliance: SOX and financial audit requirements
- Troubleshooting: Complete history for investigating issues
- Accountability: Track who did what and when
- Recovery: Ability to understand and potentially reverse actions
- Analytics: Data for process improvement and reporting
Implementation Architecture
Audit Database Schema
Comprehensive schema for tracking all activities:
-- MepApps.Dash.Ar.Maint.PaymentReversal/Resources/SQL/CREATE TABLE CG_ArReversePaymentAuditLog.sql
CREATE TABLE [dbo].[CG_ArReversePaymentAuditLog](
[AuditId] [bigint] IDENTITY(1,1) NOT NULL,
[EventType] [varchar](50) NOT NULL,
[EventSubType] [varchar](50) NULL,
[EventDate] [datetime2](7) NOT NULL DEFAULT SYSDATETIME(),
-- User Information
[UserId] [varchar](50) NOT NULL,
[UserName] [varchar](100) NULL,
[MachineName] [varchar](100) NULL,
[IPAddress] [varchar](45) NULL,
[SessionId] [uniqueidentifier] NULL,
-- Entity Information
[EntityType] [varchar](50) NULL,
[EntityId] [varchar](100) NULL,
[EntityData] [nvarchar](max) NULL, -- JSON serialized entity
-- Action Details
[ActionType] [varchar](50) NULL, -- Create, Update, Delete, Post, etc.
[ActionStatus] [varchar](20) NULL, -- Success, Failed, Partial
[ActionDuration] [int] NULL, -- Milliseconds
-- Change Tracking
[OldValues] [nvarchar](max) NULL, -- JSON of previous values
[NewValues] [nvarchar](max) NULL, -- JSON of new values
[ChangedFields] [nvarchar](500) NULL, -- Comma-separated field names
-- Context Information
[CorrelationId] [uniqueidentifier] NULL,
[ParentAuditId] [bigint] NULL,
[BatchId] [uniqueidentifier] NULL,
-- Additional Metadata
[ErrorMessage] [nvarchar](max) NULL,
[StackTrace] [nvarchar](max) NULL,
[AdditionalData] [nvarchar](max) NULL, -- JSON for extensibility
CONSTRAINT [PK_CG_ArReversePaymentAuditLog] PRIMARY KEY CLUSTERED ([AuditId] ASC)
)
-- Indexes for query performance
CREATE NONCLUSTERED INDEX [IX_Audit_EventDate]
ON [dbo].[CG_ArReversePaymentAuditLog] ([EventDate] DESC)
INCLUDE ([EventType], [UserId], [ActionStatus])
CREATE NONCLUSTERED INDEX [IX_Audit_UserId]
ON [dbo].[CG_ArReversePaymentAuditLog] ([UserId], [EventDate] DESC)
CREATE NONCLUSTERED INDEX [IX_Audit_EntityId]
ON [dbo].[CG_ArReversePaymentAuditLog] ([EntityType], [EntityId])
CREATE NONCLUSTERED INDEX [IX_Audit_Correlation]
ON [dbo].[CG_ArReversePaymentAuditLog] ([CorrelationId])
-- Completion history table
CREATE TABLE [dbo].[CG_ArReversePaymentPostCompletionHistory](
[Id] [int] IDENTITY(1,1) NOT NULL,
[PostDate] [datetime] NOT NULL,
[PostedBy] [varchar](50) NULL,
[PostSucceeded] [bit] NOT NULL,
[ItemsProcessed] [int] NULL,
[ItemsInvalid] [int] NULL,
[JournalCount] [int] NULL,
[PaymentCount] [int] NULL,
[PaymentTotal] [decimal](18, 2) NULL,
[PostPeriod] [varchar](10) NULL,
-- SYSPRO Integration Details
[BusinessObject] [varchar](20) NULL,
[InputXml] [nvarchar](max) NULL,
[ParamXml] [nvarchar](max) NULL,
[OutputXml] [nvarchar](max) NULL,
-- Error Tracking
[ErrorMessage] [nvarchar](500) NULL,
[ErrorCode] [varchar](20) NULL,
-- Audit Link
[AuditId] [bigint] NULL,
CONSTRAINT [PK_CG_ArReversePaymentPostCompletionHistory] PRIMARY KEY CLUSTERED ([Id] ASC),
CONSTRAINT [FK_PostCompletion_Audit] FOREIGN KEY ([AuditId])
REFERENCES [dbo].[CG_ArReversePaymentAuditLog] ([AuditId])
)
Audit Service Implementation
Core service for recording audit events:
// MepApps.Dash.Ar.Maint.PaymentReversal/Services/AuditService.cs
public class AuditService : IAuditService
{
private readonly ILogger<AuditService> _logger;
private readonly IServiceProvider _serviceProvider;
private readonly IUserContextService _userContext;
private readonly AsyncLocal<AuditContext> _auditContext = new();
public async Task<long> LogEventAsync(AuditEvent auditEvent)
{
try
{
using (var context = _serviceProvider.GetService<PluginSysproDataContext>())
{
var auditLog = new CG_ArReversePaymentAuditLog
{
EventType = auditEvent.EventType,
EventSubType = auditEvent.EventSubType,
EventDate = DateTime.UtcNow,
// User information
UserId = _userContext.GetCurrentUserId(),
UserName = _userContext.GetCurrentUserName(),
MachineName = Environment.MachineName,
IPAddress = _userContext.GetClientIPAddress(),
SessionId = _userContext.GetSessionId(),
// Entity information
EntityType = auditEvent.EntityType,
EntityId = auditEvent.EntityId,
EntityData = JsonSerializer.Serialize(auditEvent.Entity),
// Action details
ActionType = auditEvent.ActionType,
ActionStatus = auditEvent.ActionStatus,
ActionDuration = auditEvent.Duration?.Milliseconds,
// Context
CorrelationId = _auditContext.Value?.CorrelationId ?? Guid.NewGuid(),
ParentAuditId = _auditContext.Value?.ParentAuditId,
BatchId = _auditContext.Value?.BatchId,
// Additional data
AdditionalData = auditEvent.AdditionalData != null
? JsonSerializer.Serialize(auditEvent.AdditionalData)
: null
};
// Handle change tracking
if (auditEvent.Changes != null)
{
auditLog.OldValues = JsonSerializer.Serialize(auditEvent.Changes.OldValues);
auditLog.NewValues = JsonSerializer.Serialize(auditEvent.Changes.NewValues);
auditLog.ChangedFields = string.Join(",", auditEvent.Changes.ChangedFields);
}
// Handle errors
if (auditEvent.Exception != null)
{
auditLog.ErrorMessage = auditEvent.Exception.Message;
auditLog.StackTrace = auditEvent.Exception.StackTrace;
}
context.CG_ArReversePaymentAuditLogs.Add(auditLog);
await context.SaveChangesAsync();
_logger.LogDebug("Audit event logged: {EventType} - {EntityId}",
auditEvent.EventType, auditEvent.EntityId);
return auditLog.AuditId;
}
}
catch (Exception ex)
{
// Audit logging should not fail the main operation
_logger.LogError(ex, "Failed to log audit event: {EventType}",
auditEvent.EventType);
return -1;
}
}
public IDisposable BeginAuditScope(string scopeName, Guid? correlationId = null)
{
var parentContext = _auditContext.Value;
_auditContext.Value = new AuditContext
{
ScopeName = scopeName,
CorrelationId = correlationId ?? parentContext?.CorrelationId ?? Guid.NewGuid(),
ParentAuditId = parentContext?.CurrentAuditId,
BatchId = parentContext?.BatchId,
StartTime = DateTime.UtcNow
};
return new AuditScope(this, _auditContext.Value);
}
public async Task<AuditTrail> GetAuditTrailAsync(
string entityType,
string entityId,
DateTime? fromDate = null,
DateTime? toDate = null)
{
using (var context = _serviceProvider.GetService<PluginSysproDataContext>())
{
var query = context.CG_ArReversePaymentAuditLogs
.Where(a => a.EntityType == entityType && a.EntityId == entityId);
if (fromDate.HasValue)
query = query.Where(a => a.EventDate >= fromDate.Value);
if (toDate.HasValue)
query = query.Where(a => a.EventDate <= toDate.Value);
var events = await query
.OrderByDescending(a => a.EventDate)
.Select(a => new AuditTrailEvent
{
AuditId = a.AuditId,
EventType = a.EventType,
EventDate = a.EventDate,
UserId = a.UserId,
UserName = a.UserName,
ActionType = a.ActionType,
ActionStatus = a.ActionStatus,
OldValues = a.OldValues,
NewValues = a.NewValues,
ChangedFields = a.ChangedFields,
ErrorMessage = a.ErrorMessage
})
.ToListAsync();
return new AuditTrail
{
EntityType = entityType,
EntityId = entityId,
Events = events,
Timeline = BuildTimeline(events)
};
}
}
}
public class AuditScope : IDisposable
{
private readonly IAuditService _auditService;
private readonly AuditContext _context;
public AuditScope(IAuditService auditService, AuditContext context)
{
_auditService = auditService;
_context = context;
}
public void Dispose()
{
var duration = DateTime.UtcNow - _context.StartTime;
_auditService.LogEventAsync(new AuditEvent
{
EventType = "ScopeCompleted",
EventSubType = _context.ScopeName,
ActionStatus = "Success",
Duration = duration,
AdditionalData = new { ScopeDurationMs = duration.TotalMilliseconds }
}).Wait();
}
}
Audit Interceptor Pattern
Automatic audit logging through interceptors:
// MepApps.Dash.Ar.Maint.PaymentReversal/Services/AuditInterceptor.cs
public class AuditInterceptor : IInterceptor
{
private readonly IAuditService _auditService;
private readonly ILogger<AuditInterceptor> _logger;
public void Intercept(IInvocation invocation)
{
var auditAttribute = invocation.Method.GetCustomAttribute<AuditAttribute>();
if (auditAttribute == null)
{
invocation.Proceed();
return;
}
var stopwatch = Stopwatch.StartNew();
var auditEvent = new AuditEvent
{
EventType = auditAttribute.EventType ?? invocation.Method.Name,
EntityType = invocation.TargetType.Name,
ActionType = invocation.Method.Name,
AdditionalData = new Dictionary<string, object>()
};
try
{
// Capture parameters
CaptureParameters(invocation, auditEvent);
// Execute method
invocation.Proceed();
// Capture result
if (invocation.ReturnValue != null)
{
auditEvent.AdditionalData["Result"] = invocation.ReturnValue;
}
auditEvent.ActionStatus = "Success";
}
catch (Exception ex)
{
auditEvent.ActionStatus = "Failed";
auditEvent.Exception = ex;
_logger.LogError(ex, "Method {Method} failed", invocation.Method.Name);
throw;
}
finally
{
stopwatch.Stop();
auditEvent.Duration = stopwatch.Elapsed;
// Log audit event asynchronously
Task.Run(() => _auditService.LogEventAsync(auditEvent));
}
}
private void CaptureParameters(IInvocation invocation, AuditEvent auditEvent)
{
var parameters = invocation.Method.GetParameters();
for (int i = 0; i < parameters.Length; i++)
{
var param = parameters[i];
var value = invocation.Arguments[i];
// Don't log sensitive parameters
if (param.GetCustomAttribute<SensitiveAttribute>() != null)
{
auditEvent.AdditionalData[$"param_{param.Name}"] = "[REDACTED]";
}
else
{
auditEvent.AdditionalData[$"param_{param.Name}"] = value;
}
}
}
}
[AttributeUsage(AttributeTargets.Method)]
public class AuditAttribute : Attribute
{
public string EventType { get; set; }
public bool IncludeParameters { get; set; } = true;
public bool IncludeResult { get; set; } = true;
}
Change Tracking Implementation
Tracking entity changes for audit:
// MepApps.Dash.Ar.Maint.PaymentReversal/Services/ChangeTracker.cs
public class ChangeTracker<T> where T : class
{
private readonly Dictionary<string, object> _originalValues = new();
private readonly T _entity;
public ChangeTracker(T entity)
{
_entity = entity;
CaptureOriginalValues();
}
private void CaptureOriginalValues()
{
var properties = typeof(T).GetProperties()
.Where(p => p.CanRead && p.CanWrite);
foreach (var property in properties)
{
_originalValues[property.Name] = property.GetValue(_entity);
}
}
public ChangeSet GetChanges()
{
var changes = new ChangeSet
{
EntityType = typeof(T).Name,
ChangedFields = new List<string>(),
OldValues = new Dictionary<string, object>(),
NewValues = new Dictionary<string, object>()
};
foreach (var property in typeof(T).GetProperties())
{
var originalValue = _originalValues.GetValueOrDefault(property.Name);
var currentValue = property.GetValue(_entity);
if (!Equals(originalValue, currentValue))
{
changes.ChangedFields.Add(property.Name);
changes.OldValues[property.Name] = originalValue;
changes.NewValues[property.Name] = currentValue;
}
}
changes.HasChanges = changes.ChangedFields.Any();
return changes;
}
}
public class ChangeSet
{
public string EntityType { get; set; }
public List<string> ChangedFields { get; set; }
public Dictionary<string, object> OldValues { get; set; }
public Dictionary<string, object> NewValues { get; set; }
public bool HasChanges { get; set; }
public string GetChangeDescription()
{
if (!HasChanges)
return "No changes";
var descriptions = new List<string>();
foreach (var field in ChangedFields)
{
var oldVal = OldValues[field];
var newVal = NewValues[field];
descriptions.Add($"{field}: '{oldVal}' → '{newVal}'");
}
return string.Join(", ", descriptions);
}
}
Service Method Auditing
Auditing service operations:
// MepApps.Dash.Ar.Maint.PaymentReversal/Services/ArReversePaymentService.cs
public class ArReversePaymentService : IArReversePaymentService
{
private readonly IAuditService _auditService;
[Audit(EventType = "PaymentReversal")]
public async Task<ArReversePaymentPostCompletion> ReversePaymentsAsync(
IEnumerable<ArReversePaymentHeader> checks,
IEnumerable<ArReversePaymentDetail> invoices,
string postPeriod)
{
using (var auditScope = _auditService.BeginAuditScope("PaymentReversal"))
{
// Log start of reversal
await _auditService.LogEventAsync(new AuditEvent
{
EventType = "PaymentReversalStarted",
EntityType = "PaymentBatch",
EntityId = Guid.NewGuid().ToString(),
ActionType = "ReversePayments",
AdditionalData = new
{
PaymentCount = checks.Count(),
InvoiceCount = invoices.Count(),
PostPeriod = postPeriod,
TotalAmount = checks.Sum(c => c.CheckValue ?? 0)
}
});
try
{
// Perform reversal
var result = await PerformReversalInternal(checks, invoices, postPeriod);
// Log successful completion
await LogReversalCompletion(result, checks);
return result;
}
catch (Exception ex)
{
// Log failure
await _auditService.LogEventAsync(new AuditEvent
{
EventType = "PaymentReversalFailed",
ActionStatus = "Failed",
Exception = ex,
AdditionalData = new
{
PaymentCount = checks.Count(),
ErrorType = ex.GetType().Name
}
});
throw;
}
}
}
private async Task LogReversalCompletion(
ArReversePaymentPostCompletion completion,
IEnumerable<ArReversePaymentHeader> originalPayments)
{
// Create completion history record
using (var context = _serviceProvider.GetService<PluginSysproDataContext>())
{
var history = new CG_ArReversePaymentPostCompletionHistory
{
PostDate = completion.PostDate,
PostedBy = _userContext.GetCurrentUserId(),
PostSucceeded = completion.PostSucceeded,
ItemsProcessed = completion.ItemsProcessed,
ItemsInvalid = completion.ItemsInvalid,
JournalCount = completion.JournalCount,
PaymentCount = originalPayments.Count(),
PaymentTotal = originalPayments.Sum(p => p.CheckValue ?? 0),
PostPeriod = completion.PostPeriod,
BusinessObject = "ARSTPY",
InputXml = completion.InputXml,
ParamXml = completion.ParamXml,
OutputXml = completion.OutputXml,
ErrorMessage = completion.ErrorMessage,
ErrorCode = completion.ErrorCode
};
context.CG_ArReversePaymentPostCompletionHistories.Add(history);
await context.SaveChangesAsync();
// Log audit event
await _auditService.LogEventAsync(new AuditEvent
{
EventType = "PaymentReversalCompleted",
EntityType = "PostCompletion",
EntityId = history.Id.ToString(),
Entity = history,
ActionType = "Create",
ActionStatus = completion.PostSucceeded ? "Success" : "Failed",
AdditionalData = new
{
JournalsCreated = completion.JournalCount,
PaymentsProcessed = completion.ItemsProcessed,
PaymentsFailed = completion.ItemsInvalid
}
});
}
}
}
Audit Query and Reporting
Querying audit data for reporting:
// MepApps.Dash.Ar.Maint.PaymentReversal/Services/AuditReportingService.cs
public class AuditReportingService
{
public async Task<AuditReport> GenerateAuditReportAsync(
DateTime fromDate,
DateTime toDate,
string userId = null)
{
using (var context = _serviceProvider.GetService<PluginSysproDataContext>())
{
var query = context.CG_ArReversePaymentAuditLogs
.Where(a => a.EventDate >= fromDate && a.EventDate <= toDate);
if (!string.IsNullOrEmpty(userId))
{
query = query.Where(a => a.UserId == userId);
}
// Activity summary
var activitySummary = await query
.GroupBy(a => new { a.EventType, a.ActionStatus })
.Select(g => new ActivitySummary
{
EventType = g.Key.EventType,
Status = g.Key.ActionStatus,
Count = g.Count(),
TotalDuration = g.Sum(a => a.ActionDuration ?? 0)
})
.ToListAsync();
// User activity
var userActivity = await query
.GroupBy(a => a.UserId)
.Select(g => new UserActivity
{
UserId = g.Key,
EventCount = g.Count(),
SuccessCount = g.Count(a => a.ActionStatus == "Success"),
FailureCount = g.Count(a => a.ActionStatus == "Failed"),
LastActivity = g.Max(a => a.EventDate)
})
.ToListAsync();
// Failed operations
var failures = await query
.Where(a => a.ActionStatus == "Failed")
.Select(a => new FailedOperation
{
AuditId = a.AuditId,
EventType = a.EventType,
EventDate = a.EventDate,
UserId = a.UserId,
ErrorMessage = a.ErrorMessage,
EntityId = a.EntityId
})
.ToListAsync();
// Posting history
var postingHistory = await context.CG_ArReversePaymentPostCompletionHistories
.Where(h => h.PostDate >= fromDate && h.PostDate <= toDate)
.Select(h => new PostingSummary
{
PostDate = h.PostDate,
PostedBy = h.PostedBy,
Success = h.PostSucceeded,
ItemsProcessed = h.ItemsProcessed ?? 0,
PaymentTotal = h.PaymentTotal ?? 0,
ErrorMessage = h.ErrorMessage
})
.ToListAsync();
return new AuditReport
{
ReportDate = DateTime.Now,
FromDate = fromDate,
ToDate = toDate,
ActivitySummary = activitySummary,
UserActivity = userActivity,
FailedOperations = failures,
PostingHistory = postingHistory,
Statistics = new AuditStatistics
{
TotalEvents = activitySummary.Sum(a => a.Count),
UniqueUsers = userActivity.Count,
SuccessRate = CalculateSuccessRate(activitySummary),
AverageProcessingTime = CalculateAverageTime(activitySummary),
TotalReversed = postingHistory.Sum(p => p.PaymentTotal)
}
};
}
}
}
Audit UI Components
UI for viewing audit history:
<!-- Audit History View -->
<UserControl x:Class="MepApps.Dash.Ar.Maint.PaymentReversal.Views.AuditHistoryView">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Filters -->
<GroupBox Grid.Row="0" Header="Filters" Margin="5">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="150"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="150"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="150"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Label Grid.Column="0" Content="From:"/>
<DatePicker Grid.Column="1" SelectedDate="{Binding FromDate}"/>
<Label Grid.Column="2" Content="To:"/>
<DatePicker Grid.Column="3" SelectedDate="{Binding ToDate}"/>
<Label Grid.Column="4" Content="User:"/>
<ComboBox Grid.Column="5"
ItemsSource="{Binding Users}"
SelectedItem="{Binding SelectedUser}"/>
<Button Grid.Column="7" Content="Search"
Command="{Binding SearchCommand}"/>
</Grid>
</GroupBox>
<!-- Audit Log Grid -->
<DataGrid Grid.Row="1"
ItemsSource="{Binding AuditEvents}"
AutoGenerateColumns="False">
<DataGrid.Columns>
<DataGridTextColumn Header="Date/Time"
Binding="{Binding EventDate,
StringFormat='{}{0:yyyy-MM-dd HH:mm:ss}'}"
Width="150"/>
<DataGridTextColumn Header="User"
Binding="{Binding UserName}"
Width="100"/>
<DataGridTextColumn Header="Event"
Binding="{Binding EventType}"
Width="150"/>
<DataGridTextColumn Header="Action"
Binding="{Binding ActionType}"
Width="100"/>
<DataGridTextColumn Header="Status"
Binding="{Binding ActionStatus}"
Width="80">
<DataGridTextColumn.CellStyle>
<Style TargetType="DataGridCell">
<Style.Triggers>
<DataTrigger Binding="{Binding ActionStatus}"
Value="Failed">
<Setter Property="Foreground" Value="Red"/>
</DataTrigger>
<DataTrigger Binding="{Binding ActionStatus}"
Value="Success">
<Setter Property="Foreground" Value="Green"/>
</DataTrigger>
</Style.Triggers>
</Style>
</DataGridTextColumn.CellStyle>
</DataGridTextColumn>
<DataGridTextColumn Header="Entity"
Binding="{Binding EntityId}"
Width="150"/>
<DataGridTemplateColumn Header="Details" Width="100">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Button Content="View"
Command="{Binding DataContext.ViewDetailsCommand,
RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding}"/>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</Grid>
</UserControl>
Benefits
- Complete Traceability: Every action is recorded
- Compliance Ready: Meets audit requirements
- Issue Resolution: Quick troubleshooting with full context
- Performance Monitoring: Track operation durations
- User Accountability: Clear record of who did what
Related Documentation
Summary
The audit history tracking system provides comprehensive visibility into all AR Payment Reversal operations. Through automatic tracking, detailed change logging, and powerful querying capabilities, the system ensures compliance requirements are met while providing valuable operational insights for continuous improvement.