Utility Services
Overview
The utility services layer provides essential helper functionality and cross-cutting concerns that support the core business operations of the AR Payment Reversal dashboard. This document details the implementation of configuration management, validation services, custom forms handling, resource management, Excel export capabilities, and logging integration.
Key Concepts
- Configuration Management: MepSettingsService for application settings
- Database Validation: Schema verification and table creation
- Custom Forms: Dynamic form configuration and validation
- Resource Management: Embedded resource handling
- Excel Export: Report generation and data export
- Logging Integration: Structured logging with Serilog
Implementation Details
MepSettingsService
Settings Management Implementation
// MepApps.Dash.Ar.Maint.PaymentReversal/Services/MepSettingsService.cs
public interface IMepSettingsService
{
Task<MepSettings> GetSettingsAsync();
Task SaveSettingsAsync(MepSettings settings);
Task<T> GetSettingAsync<T>(string key);
Task SetSettingAsync<T>(string key, T value);
void ReloadSettings();
}
public class MepSettingsService : IMepSettingsService
{
private readonly ILogger<MepSettingsService> _logger;
private readonly string _settingsPath;
private MepSettings _cachedSettings;
private readonly SemaphoreSlim _settingsLock = new(1, 1);
private FileSystemWatcher _fileWatcher;
public MepSettingsService(ILogger<MepSettingsService> logger)
{
_logger = logger;
_settingsPath = Path.Combine(
AppDomain.CurrentDomain.BaseDirectory,
"Resources",
"MepSettings.json");
InitializeFileWatcher();
LoadSettings();
}
private void InitializeFileWatcher()
{
var directory = Path.GetDirectoryName(_settingsPath);
var fileName = Path.GetFileName(_settingsPath);
_fileWatcher = new FileSystemWatcher(directory, fileName)
{
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size
};
_fileWatcher.Changed += OnSettingsFileChanged;
_fileWatcher.EnableRaisingEvents = true;
}
private void OnSettingsFileChanged(object sender, FileSystemEventArgs e)
{
_logger.LogInformation("Settings file changed, reloading settings");
ReloadSettings();
}
public async Task<MepSettings> GetSettingsAsync()
{
await _settingsLock.WaitAsync();
try
{
if (_cachedSettings == null)
{
await LoadSettingsAsync();
}
// Return a clone to prevent external modifications
return _cachedSettings?.DeepClone();
}
finally
{
_settingsLock.Release();
}
}
private async Task LoadSettingsAsync()
{
try
{
if (File.Exists(_settingsPath))
{
var json = await File.ReadAllTextAsync(_settingsPath);
_cachedSettings = JsonSerializer.Deserialize<MepSettings>(json,
new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip
});
_logger.LogDebug("Settings loaded successfully. {@SettingsLoadContext}",
new { path = _settingsPath, settingsCount = _cachedSettings?.GetType().GetProperties().Length });
}
else
{
_logger.LogWarning("Settings file not found at {Path}, using defaults", _settingsPath);
_cachedSettings = CreateDefaultSettings();
await SaveSettingsAsync(_cachedSettings);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading settings from {Path}", _settingsPath);
_cachedSettings = CreateDefaultSettings();
}
}
public async Task SaveSettingsAsync(MepSettings settings)
{
await _settingsLock.WaitAsync();
try
{
var json = JsonSerializer.Serialize(settings,
new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
// Disable file watcher during save to prevent recursive events
_fileWatcher.EnableRaisingEvents = false;
await File.WriteAllTextAsync(_settingsPath, json);
_cachedSettings = settings.DeepClone();
_logger.LogInformation("Settings saved successfully to {Path}", _settingsPath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving settings to {Path}", _settingsPath);
throw;
}
finally
{
_fileWatcher.EnableRaisingEvents = true;
_settingsLock.Release();
}
}
private MepSettings CreateDefaultSettings()
{
return new MepSettings
{
DefaultBank = "MAIN",
PostingBatchSize = 50,
EnableAutoQueue = false,
ValidationLevel = "Standard",
ExportPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "AR Reversals"),
RetryAttempts = 3,
TimeoutSeconds = 60
};
}
}
// MepApps.Dash.Ar.Maint.PaymentReversal/Models/MepSettings.cs
public class MepSettings : ICloneable
{
public string DefaultBank { get; set; }
public int PostingBatchSize { get; set; }
public bool EnableAutoQueue { get; set; }
public string ValidationLevel { get; set; }
public string ExportPath { get; set; }
public int RetryAttempts { get; set; }
public int TimeoutSeconds { get; set; }
public object Clone()
{
return MemberwiseClone();
}
public MepSettings DeepClone()
{
var json = JsonSerializer.Serialize(this);
return JsonSerializer.Deserialize<MepSettings>(json);
}
}
DatabaseValidationService
Schema Validation and Table Creation
// MepApps.Dash.Ar.Maint.PaymentReversal/Services/DatabaseValidationService.cs
public interface IDatabaseValidationService
{
Task InitializeAsync();
Task<bool> ValidateDatabaseSchemaAsync();
Task CreateMissingDatabaseTablesAsync();
Task<IEnumerable<DatabaseValidationObject>> GetValidationResultsAsync();
}
public class DatabaseValidationService : IDatabaseValidationService
{
private readonly ILogger<DatabaseValidationService> _logger;
private readonly string _validationConfigPath;
private List<DatabaseValidationObject> _validationObjects;
public DatabaseValidationService(ILogger<DatabaseValidationService> logger)
{
_logger = logger;
_validationConfigPath = Path.Combine(
AppDomain.CurrentDomain.BaseDirectory,
"Resources",
"DatabaseValidation.json");
}
public async Task InitializeAsync()
{
try
{
_logger.LogInformation("Initializing database validation service");
// Load validation configuration
await LoadValidationConfigurationAsync();
// Validate current schema
await ValidateDatabaseSchemaAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error initializing database validation service");
throw;
}
}
private async Task LoadValidationConfigurationAsync()
{
try
{
if (File.Exists(_validationConfigPath))
{
var json = await File.ReadAllTextAsync(_validationConfigPath);
_validationObjects = JsonSerializer.Deserialize<List<DatabaseValidationObject>>(json);
_logger.LogDebug("Loaded {Count} validation objects", _validationObjects?.Count ?? 0);
}
else
{
_logger.LogWarning("Validation configuration not found at {Path}", _validationConfigPath);
_validationObjects = GetDefaultValidationObjects();
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading validation configuration");
_validationObjects = GetDefaultValidationObjects();
}
}
private List<DatabaseValidationObject> GetDefaultValidationObjects()
{
return new List<DatabaseValidationObject>
{
new DatabaseValidationObject
{
Name = "CG_ArReversePaymentQueueHeader",
ObjectType = "Table",
ResourceFile = "CREATE TABLE CG_ArReversePaymentQueueHeader.sql"
},
new DatabaseValidationObject
{
Name = "CG_ArReversePaymentPostCompletionHistory",
ObjectType = "Table",
ResourceFile = "CREATE TABLE CG_ArReversePaymentHistory.sql"
}
};
}
public async Task<bool> ValidateDatabaseSchemaAsync()
{
var allValid = true;
using (var context = MainView.MepPluginServiceProvider.GetService<PluginSysproDataContext>())
{
foreach (var validationObject in _validationObjects)
{
try
{
var exists = await CheckObjectExistsAsync(context, validationObject);
validationObject.Exists = exists;
validationObject.ValidationDate = DateTime.Now;
if (!exists)
{
_logger.LogWarning("Database object {Name} does not exist", validationObject.Name);
allValid = false;
}
else
{
_logger.LogDebug("Database object {Name} validated successfully", validationObject.Name);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error validating database object {Name}", validationObject.Name);
validationObject.Exists = false;
validationObject.Error = ex.Message;
allValid = false;
}
}
}
return allValid;
}
private async Task<bool> CheckObjectExistsAsync(PluginSysproDataContext context, DatabaseValidationObject obj)
{
string sql = obj.ObjectType switch
{
"Table" => $@"
SELECT COUNT(*)
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_NAME = '{obj.Name}'",
"StoredProcedure" => $@"
SELECT COUNT(*)
FROM INFORMATION_SCHEMA.ROUTINES
WHERE ROUTINE_NAME = '{obj.Name}'
AND ROUTINE_TYPE = 'PROCEDURE'",
"View" => $@"
SELECT COUNT(*)
FROM INFORMATION_SCHEMA.VIEWS
WHERE TABLE_NAME = '{obj.Name}'",
_ => throw new NotSupportedException($"Object type {obj.ObjectType} not supported")
};
var count = await context.Database.SqlQuery<int>(sql).FirstOrDefaultAsync();
return count > 0;
}
public async Task CreateMissingDatabaseTablesAsync()
{
var missingObjects = _validationObjects.Where(x => !x.Exists && x.ObjectType == "Table");
foreach (var missingObject in missingObjects)
{
try
{
_logger.LogInformation("Creating missing table {Name}", missingObject.Name);
var sql = await LoadResourceSqlAsync(missingObject.ResourceFile);
using (var context = MainView.MepPluginServiceProvider.GetService<PluginSysproDataContext>())
{
await context.Database.ExecuteSqlCommandAsync(sql);
}
missingObject.Exists = true;
missingObject.CreatedDate = DateTime.Now;
_logger.LogInformation("Successfully created table {Name}", missingObject.Name);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating table {Name}", missingObject.Name);
throw;
}
}
}
private async Task<string> LoadResourceSqlAsync(string resourceFile)
{
var resourcePath = Path.Combine(
AppDomain.CurrentDomain.BaseDirectory,
"Resources",
"SQL",
resourceFile);
if (!File.Exists(resourcePath))
{
throw new FileNotFoundException($"SQL resource file not found: {resourcePath}");
}
return await File.ReadAllTextAsync(resourcePath);
}
}
// MepApps.Dash.Ar.Maint.PaymentReversal/Models/DatabaseValidationObject.cs
public class DatabaseValidationObject
{
public string Name { get; set; }
public string ObjectType { get; set; }
public string ResourceFile { get; set; }
public bool Exists { get; set; }
public DateTime? ValidationDate { get; set; }
public DateTime? CreatedDate { get; set; }
public string Error { get; set; }
}
CustomFormService
Dynamic Form Configuration
// MepApps.Dash.Ar.Maint.PaymentReversal/Services/CustomFormService.cs
public interface ICustomFormService
{
Task<IEnumerable<CustomFormField>> GetFormFieldsAsync(string formName);
Task<bool> ValidateFormDataAsync(string formName, Dictionary<string, object> formData);
Task SaveFormConfigurationAsync(string formName, IEnumerable<CustomFormField> fields);
}
public class CustomFormService : ICustomFormService
{
private readonly ILogger<CustomFormService> _logger;
private readonly string _customFormsPath;
private XDocument _formsDocument;
public CustomFormService(ILogger<CustomFormService> logger)
{
_logger = logger;
_customFormsPath = Path.Combine(
AppDomain.CurrentDomain.BaseDirectory,
"Resources",
"CustomForms.xml");
LoadFormsConfiguration();
}
private void LoadFormsConfiguration()
{
try
{
if (File.Exists(_customFormsPath))
{
_formsDocument = XDocument.Load(_customFormsPath);
_logger.LogDebug("Custom forms configuration loaded from {Path}", _customFormsPath);
}
else
{
_formsDocument = new XDocument(
new XElement("CustomForms",
new XComment("Custom form definitions")
)
);
_logger.LogWarning("Custom forms file not found, created empty configuration");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading custom forms configuration");
_formsDocument = new XDocument(new XElement("CustomForms"));
}
}
public async Task<IEnumerable<CustomFormField>> GetFormFieldsAsync(string formName)
{
return await Task.Run(() =>
{
var formElement = _formsDocument.Root?
.Elements("Form")
.FirstOrDefault(f => f.Attribute("name")?.Value == formName);
if (formElement == null)
{
_logger.LogWarning("Form {FormName} not found in configuration", formName);
return Enumerable.Empty<CustomFormField>();
}
var fields = formElement.Elements("Field")
.Select(f => new CustomFormField
{
Name = f.Attribute("name")?.Value,
Label = f.Attribute("label")?.Value,
Type = f.Attribute("type")?.Value ?? "text",
Required = bool.Parse(f.Attribute("required")?.Value ?? "false"),
MaxLength = int.Parse(f.Attribute("maxLength")?.Value ?? "0"),
ValidationPattern = f.Attribute("pattern")?.Value,
DefaultValue = f.Attribute("default")?.Value,
Options = f.Elements("Option")
.Select(o => new SelectionItem
{
Value = o.Attribute("value")?.Value,
Description = o.Value
}).ToList()
})
.ToList();
_logger.LogDebug("Loaded {Count} fields for form {FormName}", fields.Count, formName);
return fields;
});
}
public async Task<bool> ValidateFormDataAsync(string formName, Dictionary<string, object> formData)
{
var fields = await GetFormFieldsAsync(formName);
var errors = new List<string>();
foreach (var field in fields)
{
if (field.Required && !formData.ContainsKey(field.Name))
{
errors.Add($"Required field '{field.Label}' is missing");
continue;
}
if (formData.TryGetValue(field.Name, out var value))
{
// Type validation
if (!ValidateFieldType(field, value))
{
errors.Add($"Field '{field.Label}' has invalid type");
}
// Pattern validation
if (!string.IsNullOrEmpty(field.ValidationPattern))
{
var regex = new Regex(field.ValidationPattern);
if (!regex.IsMatch(value?.ToString() ?? string.Empty))
{
errors.Add($"Field '{field.Label}' does not match required pattern");
}
}
// Length validation
if (field.MaxLength > 0 && value?.ToString().Length > field.MaxLength)
{
errors.Add($"Field '{field.Label}' exceeds maximum length of {field.MaxLength}");
}
}
}
if (errors.Any())
{
_logger.LogWarning("Form validation failed for {FormName}. Errors: {Errors}",
formName, string.Join(", ", errors));
return false;
}
return true;
}
private bool ValidateFieldType(CustomFormField field, object value)
{
return field.Type switch
{
"text" or "string" => value is string,
"number" or "int" => value is int or long,
"decimal" or "money" => value is decimal or double,
"date" or "datetime" => value is DateTime,
"bool" or "boolean" => value is bool,
_ => true
};
}
}
// MepApps.Dash.Ar.Maint.PaymentReversal/Models/CustomFormField.cs
public class CustomFormField
{
public string Name { get; set; }
public string Label { get; set; }
public string Type { get; set; }
public bool Required { get; set; }
public int MaxLength { get; set; }
public string ValidationPattern { get; set; }
public string DefaultValue { get; set; }
public List<SelectionItem> Options { get; set; }
}
Excel Export Service
Report Generation
// Referenced from ArReversePaymentService.cs (Lines 541-600)
public async Task ExportToExcel(ArReversePaymentPostCompletion completionObject, string filePath)
{
try
{
// Prepare header data
IEnumerable<Export_Header> header = new List<Export_Header>(new Export_Header[]
{
new Export_Header
{
ItemsProcessed = completionObject.ItemsProcessed,
ItemsInvalid = completionObject.ItemsInvalid,
JournalCount = completionObject.JournalCount,
PaymentCount = completionObject.PaymentCount,
PaymentTotal = completionObject.PaymentTotal
}
});
// Prepare XML data with formatting
Export_XML xmlItem = new Export_XML
{
BusinessObject = completionObject.BusinessObject,
InputXml = FormatXml(completionObject.InputXml),
ParamXml = FormatXml(completionObject.ParamXml),
OutputXml = FormatXml(completionObject.OutputXml)
};
IEnumerable<Export_XML> xml = new List<Export_XML>(new Export_XML[] { xmlItem });
// Create worksheet definitions
List<ExcelWorksheetWithDataTable> worksheetParams = new List<ExcelWorksheetWithDataTable>()
{
new ExcelWorksheetWithDataTable("Post Summary", Export_CreateDataTable_Header(header)),
new ExcelWorksheetWithDataTable("Payments", Export_CreateDataTable_Payments(completionObject.Payments)),
new ExcelWorksheetWithDataTable("Post XML", Export_CreateDataTable_XML(xml))
};
// Generate Excel file
_excelExport.CreateExcelFile(
Path.GetDirectoryName(filePath),
Path.GetFileName(filePath),
worksheetParams,
null);
_logger.LogInformation("Excel export completed successfully to {FilePath}", filePath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error exporting to Excel");
throw;
}
}
private string FormatXml(string xml)
{
try
{
return XElement.Parse(xml).ToString();
}
catch
{
return xml; // Return as-is if not valid XML
}
}
Resource Helper Utilities
// MepApps.Dash.Ar.Maint.PaymentReversal/Utilities/ResourceHelper.cs
public static class ResourceHelper
{
private static readonly ILogger _logger = LogManager.GetCurrentClassLogger();
public static T LoadEmbeddedResource<T>(string resourceName)
{
try
{
var assembly = Assembly.GetExecutingAssembly();
var fullResourceName = $"{assembly.GetName().Name}.Resources.{resourceName}";
using (var stream = assembly.GetManifestResourceStream(fullResourceName))
{
if (stream == null)
{
throw new FileNotFoundException($"Embedded resource not found: {fullResourceName}");
}
using (var reader = new StreamReader(stream))
{
var content = reader.ReadToEnd();
if (typeof(T) == typeof(string))
{
return (T)(object)content;
}
if (typeof(T) == typeof(XDocument))
{
return (T)(object)XDocument.Parse(content);
}
// JSON deserialization
return JsonSerializer.Deserialize<T>(content);
}
}
}
catch (Exception ex)
{
_logger.Error(ex, $"Error loading embedded resource: {resourceName}");
throw;
}
}
public static async Task<byte[]> LoadBinaryResourceAsync(string resourceName)
{
var assembly = Assembly.GetExecutingAssembly();
var fullResourceName = $"{assembly.GetName().Name}.Resources.{resourceName}";
using (var stream = assembly.GetManifestResourceStream(fullResourceName))
{
if (stream == null)
{
throw new FileNotFoundException($"Binary resource not found: {fullResourceName}");
}
using (var memoryStream = new MemoryStream())
{
await stream.CopyToAsync(memoryStream);
return memoryStream.ToArray();
}
}
}
public static IEnumerable<string> GetEmbeddedResourceNames(string prefix = null)
{
var assembly = Assembly.GetExecutingAssembly();
var resources = assembly.GetManifestResourceNames();
if (!string.IsNullOrEmpty(prefix))
{
var fullPrefix = $"{assembly.GetName().Name}.Resources.{prefix}";
resources = resources.Where(r => r.StartsWith(fullPrefix)).ToArray();
}
return resources;
}
}
Logging Utilities
public static class LoggingExtensions
{
public static void LogMethodEntry(this ILogger logger,
[CallerMemberName] string methodName = "",
object parameters = null)
{
if (logger.IsEnabled(LogLevel.Debug))
{
logger.LogDebug("Entering {MethodName}. {@Parameters}", methodName, parameters);
}
}
public static void LogMethodExit(this ILogger logger,
[CallerMemberName] string methodName = "",
object result = null)
{
if (logger.IsEnabled(LogLevel.Debug))
{
logger.LogDebug("Exiting {MethodName}. {@Result}", methodName, result);
}
}
public static IDisposable BeginTimedOperation(this ILogger logger,
string operationName,
LogLevel logLevel = LogLevel.Information)
{
return new TimedOperation(logger, operationName, logLevel);
}
private class TimedOperation : IDisposable
{
private readonly ILogger _logger;
private readonly string _operationName;
private readonly LogLevel _logLevel;
private readonly Stopwatch _stopwatch;
public TimedOperation(ILogger logger, string operationName, LogLevel logLevel)
{
_logger = logger;
_operationName = operationName;
_logLevel = logLevel;
_stopwatch = Stopwatch.StartNew();
_logger.Log(_logLevel, "Starting {OperationName}", _operationName);
}
public void Dispose()
{
_stopwatch.Stop();
_logger.Log(_logLevel, "Completed {OperationName} in {ElapsedMs}ms",
_operationName, _stopwatch.ElapsedMilliseconds);
}
}
}
Best Practices
- Cache configuration to avoid repeated file I/O
- Use async methods for all I/O operations
- Validate settings before applying changes
- Log configuration changes for audit trail
- Handle missing resources gracefully
- Implement file watchers for dynamic reloading
- Use structured logging for better observability
Common Pitfalls
- Not handling file locks when reading/writing settings
- Missing null checks for optional configuration
- Synchronous file I/O blocking UI thread
- Not validating XML/JSON before parsing
- Memory leaks from file watchers
Related Documentation
- Service Architecture - Overall service design
- Data Services - Data access patterns
- Business Logic Services - Core business logic
- Integration Services - External integrations
Summary
The utility services layer provides essential infrastructure support through configuration management, validation, resource handling, and cross-cutting concerns. These services ensure consistent behavior across the application while maintaining separation of concerns and enabling easy testing and maintenance.