Skip to main content

Example: Custom Validation Framework

Overview

This example showcases the comprehensive validation framework implemented in the AR Payment Reversal dashboard. The multi-layered validation system ensures data integrity at every level - from UI input validation to business rule enforcement to SYSPRO compatibility checks. This framework prevents costly errors and provides clear, actionable feedback to users.

Validation Architecture

The validation framework operates at multiple levels:

  • UI Level: Immediate feedback on user input
  • Business Logic Level: Domain-specific rule enforcement
  • Data Level: Database constraint validation
  • Integration Level: SYSPRO compatibility verification
  • Cross-Field Level: Complex multi-field validations

Implementation Details

Validation Base Infrastructure

Core validation interfaces and base classes:

// MepApps.Dash.Ar.Maint.PaymentReversal/Validation/IValidator.cs
public interface IValidator<T>
{
ValidationResult Validate(T entity);
Task<ValidationResult> ValidateAsync(T entity);
}

public interface IValidationRule<T>
{
string RuleName { get; }
string ErrorMessage { get; }
ValidationSeverity Severity { get; }
bool Validate(T entity);
Task<bool> ValidateAsync(T entity);
}

public class ValidationResult
{
public bool IsValid => !Errors.Any(e => e.Severity == ValidationSeverity.Error);
public List<ValidationError> Errors { get; set; } = new();
public List<ValidationWarning> Warnings =>
Errors.Where(e => e.Severity == ValidationSeverity.Warning).ToList();

public void AddError(string field, string message, ValidationSeverity severity = ValidationSeverity.Error)
{
Errors.Add(new ValidationError
{
Field = field,
Message = message,
Severity = severity,
Timestamp = DateTime.Now
});
}

public void Merge(ValidationResult other)
{
Errors.AddRange(other.Errors);
}
}

public class ValidationError
{
public string Field { get; set; }
public string Message { get; set; }
public ValidationSeverity Severity { get; set; }
public DateTime Timestamp { get; set; }
public string Code { get; set; }
public Dictionary<string, object> Context { get; set; }
}

public enum ValidationSeverity
{
Info,
Warning,
Error,
Critical
}

Fluent Validation Builder

Creating validation rules with a fluent API:

// MepApps.Dash.Ar.Maint.PaymentReversal/Validation/FluentValidationBuilder.cs
public class ValidationBuilder<T>
{
private readonly List<IValidationRule<T>> _rules = new();

public ValidationBuilder<T> RuleFor<TProperty>(
Expression<Func<T, TProperty>> propertyExpression,
Action<PropertyValidationBuilder<T, TProperty>> configure)
{
var propertyName = GetPropertyName(propertyExpression);
var propertyFunc = propertyExpression.Compile();

var propertyBuilder = new PropertyValidationBuilder<T, TProperty>(
propertyName,
propertyFunc);

configure(propertyBuilder);
_rules.AddRange(propertyBuilder.Build());

return this;
}

public ValidationBuilder<T> Must(
Func<T, bool> predicate,
string errorMessage)
{
_rules.Add(new CustomRule<T>(predicate, errorMessage));
return this;
}

public ValidationBuilder<T> MustAsync(
Func<T, Task<bool>> predicate,
string errorMessage)
{
_rules.Add(new AsyncCustomRule<T>(predicate, errorMessage));
return this;
}

public IValidator<T> Build()
{
return new CompositeValidator<T>(_rules);
}
}

public class PropertyValidationBuilder<T, TProperty>
{
private readonly string _propertyName;
private readonly Func<T, TProperty> _propertyFunc;
private readonly List<IValidationRule<T>> _rules = new();

public PropertyValidationBuilder<T, TProperty> NotNull()
{
_rules.Add(new NotNullRule<T, TProperty>(_propertyName, _propertyFunc));
return this;
}

public PropertyValidationBuilder<T, TProperty> NotEmpty()
{
_rules.Add(new NotEmptyRule<T, TProperty>(_propertyName, _propertyFunc));
return this;
}

public PropertyValidationBuilder<T, TProperty> Length(int min, int max)
{
_rules.Add(new LengthRule<T>(_propertyName,
t => _propertyFunc(t)?.ToString(), min, max));
return this;
}

public PropertyValidationBuilder<T, TProperty> Matches(string pattern)
{
_rules.Add(new RegexRule<T>(_propertyName,
t => _propertyFunc(t)?.ToString(), pattern));
return this;
}

public PropertyValidationBuilder<T, TProperty> Range(TProperty min, TProperty max)
where TProperty : IComparable<TProperty>
{
_rules.Add(new RangeRule<T, TProperty>(_propertyName, _propertyFunc, min, max));
return this;
}

public PropertyValidationBuilder<T, TProperty> Custom(
Func<TProperty, bool> validator,
string errorMessage)
{
_rules.Add(new CustomPropertyRule<T, TProperty>(
_propertyName, _propertyFunc, validator, errorMessage));
return this;
}

public PropertyValidationBuilder<T, TProperty> WithMessage(string message)
{
if (_rules.Any())
{
var lastRule = _rules.Last();
if (lastRule is IConfigurableRule configurable)
{
configurable.ErrorMessage = message;
}
}
return this;
}

public PropertyValidationBuilder<T, TProperty> WithSeverity(ValidationSeverity severity)
{
if (_rules.Any())
{
var lastRule = _rules.Last();
if (lastRule is IConfigurableRule configurable)
{
configurable.Severity = severity;
}
}
return this;
}

public List<IValidationRule<T>> Build() => _rules;
}

Payment Reversal Validator

Complex business validation for payment reversals:

// MepApps.Dash.Ar.Maint.PaymentReversal/Validation/PaymentReversalValidator.cs
public class PaymentReversalValidator : IValidator<ArReversePaymentHeader>
{
private readonly ILogger<PaymentReversalValidator> _logger;
private readonly IArReversePaymentService _service;
private readonly ValidationBuilder<ArReversePaymentHeader> _builder;

public PaymentReversalValidator(
ILogger<PaymentReversalValidator> logger,
IArReversePaymentService service)
{
_logger = logger;
_service = service;

_builder = new ValidationBuilder<ArReversePaymentHeader>()

// Basic field validation
.RuleFor(p => p.Customer, customer => customer
.NotEmpty()
.WithMessage("Customer code is required")
.Length(1, 15)
.WithMessage("Customer code must be between 1 and 15 characters")
.Matches("^[A-Z0-9]+$")
.WithMessage("Customer code can only contain letters and numbers"))

.RuleFor(p => p.CheckNumber, check => check
.NotEmpty()
.WithMessage("Check number is required")
.Length(1, 20)
.WithMessage("Check number must be between 1 and 20 characters"))

.RuleFor(p => p.CheckValue, value => value
.NotNull()
.WithMessage("Check value is required")
.Range(0.01m, 9999999.99m)
.WithMessage("Check value must be between $0.01 and $9,999,999.99"))

.RuleFor(p => p.PaymentDate, date => date
.NotNull()
.WithMessage("Payment date is required")
.Custom(d => d <= DateTime.Today, "Payment date cannot be in the future")
.Custom(d => d >= DateTime.Today.AddYears(-2),
"Payment date cannot be more than 2 years old")
.WithSeverity(ValidationSeverity.Warning))

.RuleFor(p => p.Bank, bank => bank
.NotEmpty()
.WithMessage("Bank account is required")
.Length(1, 10)
.WithMessage("Bank code must be between 1 and 10 characters"))

// Complex business rules
.Must(ValidateNotDuplicate)
.WithMessage("Payment has already been queued for reversal")

.MustAsync(ValidateCustomerExists)
.WithMessage("Customer does not exist in SYSPRO")

.MustAsync(ValidatePaymentExists)
.WithMessage("Original payment not found in SYSPRO")

.MustAsync(ValidateNotAlreadyReversed)
.WithMessage("Payment has already been reversed")

.MustAsync(ValidateBankAccount)
.WithMessage("Bank account is not valid or is on hold");
}

public ValidationResult Validate(ArReversePaymentHeader payment)
{
return Task.Run(() => ValidateAsync(payment)).Result;
}

public async Task<ValidationResult> ValidateAsync(ArReversePaymentHeader payment)
{
var result = new ValidationResult();

try
{
_logger.LogDebug("Validating payment reversal for {Customer} - {Check}",
payment.Customer, payment.CheckNumber);

var validator = _builder.Build();
var validationResult = await validator.ValidateAsync(payment);

if (!validationResult.IsValid)
{
foreach (var error in validationResult.Errors)
{
result.AddError(error.Field, error.Message, error.Severity);
}
}

// Additional cross-field validation
await ValidateCrossFieldRules(payment, result);

// SYSPRO-specific validation
await ValidateSysproCompatibility(payment, result);

_logger.LogDebug("Validation complete. Valid: {IsValid}, Errors: {ErrorCount}",
result.IsValid, result.Errors.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during validation");
result.AddError("General", "Validation error occurred", ValidationSeverity.Critical);
}

return result;
}

private bool ValidateNotDuplicate(ArReversePaymentHeader payment)
{
using (var context = _serviceProvider.GetService<PluginSysproDataContext>())
{
return !context.CG_ArReversePaymentQueueHeaders
.Any(q => q.Customer == payment.Customer
&& q.CheckNumber == payment.CheckNumber
&& q.TrnYear == payment.TrnYear
&& q.TrnMonth == payment.TrnMonth
&& q.Journal == payment.Journal);
}
}

private async Task<bool> ValidateCustomerExists(ArReversePaymentHeader payment)
{
using (var context = _serviceProvider.GetService<PluginSysproDataContext>())
{
var customer = await context.ArCustomers
.FirstOrDefaultAsync(c => c.Customer == payment.Customer);

if (customer == null)
return false;

// Additional customer validation
if (customer.CustomerOnHold == "Y")
{
_logger.LogWarning("Customer {Customer} is on hold", payment.Customer);
}

return true;
}
}

private async Task<bool> ValidatePaymentExists(ArReversePaymentHeader payment)
{
using (var context = _serviceProvider.GetService<PluginSysproDataContext>())
{
return await context.ArPayHistories
.AnyAsync(p => p.Customer == payment.Customer
&& p.Reference == payment.CheckNumber
&& p.PaymYear == payment.TrnYear
&& p.PaymMonth == payment.TrnMonth
&& p.CashJournal == payment.Journal);
}
}

private async Task<bool> ValidateNotAlreadyReversed(ArReversePaymentHeader payment)
{
using (var context = _serviceProvider.GetService<PluginSysproDataContext>())
{
// Check in history table
var previousReversal = await context.CG_ArReversePaymentPostCompletionHistories
.AnyAsync(h => h.Customer == payment.Customer
&& h.CheckNumber == payment.CheckNumber
&& h.PostSucceeded == true);

if (previousReversal)
return false;

// Check in SYSPRO for negative payment
var negativePayment = await context.ArPayHistories
.AnyAsync(p => p.Customer == payment.Customer
&& p.Reference == payment.CheckNumber + "-REV"
&& p.PaymentValue < 0);

return !negativePayment;
}
}

private async Task<bool> ValidateBankAccount(ArReversePaymentHeader payment)
{
using (var context = _serviceProvider.GetService<PluginSysproDataContext>())
{
var bank = await context.ApBanks
.FirstOrDefaultAsync(b => b.Bank == payment.Bank);

return bank != null && bank.BankOnHold != "Y";
}
}

private async Task ValidateCrossFieldRules(
ArReversePaymentHeader payment,
ValidationResult result)
{
// Validate payment date against period
if (payment.PaymentDate.HasValue && payment.TrnYear.HasValue && payment.TrnMonth.HasValue)
{
var expectedYear = payment.PaymentDate.Value.Year;
var expectedMonth = payment.PaymentDate.Value.Month;

if (Math.Abs(expectedYear - (int)payment.TrnYear.Value) > 1)
{
result.AddError("PaymentDate",
"Payment date year does not match transaction year",
ValidationSeverity.Warning);
}

if (expectedMonth != (int)payment.TrnMonth.Value)
{
result.AddError("PaymentDate",
"Payment date month does not match transaction month",
ValidationSeverity.Warning);
}
}
}

private async Task ValidateSysproCompatibility(
ArReversePaymentHeader payment,
ValidationResult result)
{
// Validate SYSPRO field constraints
if (!string.IsNullOrEmpty(payment.Customer))
{
// SYSPRO uses uppercase for customer codes
if (payment.Customer != payment.Customer.ToUpper())
{
result.AddError("Customer",
"Customer code must be uppercase for SYSPRO",
ValidationSeverity.Warning);
}
}

// Check for SYSPRO-specific business rules
if (payment.CheckValue.HasValue && payment.CheckValue.Value > 1000000)
{
// Large payment threshold
result.AddError("CheckValue",
"Payment exceeds $1,000,000 - requires additional approval",
ValidationSeverity.Warning);
}
}
}

UI Validation Integration

Real-time validation feedback in the UI:

// MepApps.Dash.Ar.Maint.PaymentReversal/ViewModels/ValidatingViewModel.cs
public abstract class ValidatingViewModel : BaseViewModel, IDataErrorInfo
{
private readonly Dictionary<string, List<ValidationError>> _validationErrors = new();
private readonly IValidator<object> _validator;

protected ValidatingViewModel(IValidator<object> validator = null)
{
_validator = validator;
}

public string this[string propertyName]
{
get
{
ValidateProperty(propertyName);

if (_validationErrors.TryGetValue(propertyName, out var errors))
{
var errorMessages = errors
.Where(e => e.Severity >= ValidationSeverity.Error)
.Select(e => e.Message);

return string.Join(Environment.NewLine, errorMessages);
}

return null;
}
}

public string Error => string.Join(Environment.NewLine,
_validationErrors.SelectMany(kvp => kvp.Value)
.Where(e => e.Severity >= ValidationSeverity.Error)
.Select(e => e.Message));

public bool HasErrors => _validationErrors.Any(kvp =>
kvp.Value.Any(e => e.Severity >= ValidationSeverity.Error));

public bool HasWarnings => _validationErrors.Any(kvp =>
kvp.Value.Any(e => e.Severity == ValidationSeverity.Warning));

protected virtual void ValidateProperty(string propertyName)
{
_validationErrors.Remove(propertyName);

// Get property value
var property = GetType().GetProperty(propertyName);
if (property == null) return;

var value = property.GetValue(this);

// Run validation
var validationResult = ValidatePropertyValue(propertyName, value);

if (validationResult != null && validationResult.Errors.Any())
{
_validationErrors[propertyName] = validationResult.Errors
.Where(e => e.Field == propertyName)
.ToList();
}

OnPropertyChanged(nameof(HasErrors));
OnPropertyChanged(nameof(HasWarnings));
}

protected virtual ValidationResult ValidatePropertyValue(string propertyName, object value)
{
// Use validator if available
if (_validator != null)
{
return _validator.Validate(this);
}

// Use data annotations
var results = new List<System.ComponentModel.DataAnnotations.ValidationResult>();
var context = new ValidationContext(this) { MemberName = propertyName };

Validator.TryValidateProperty(value, context, results);

var validationResult = new ValidationResult();
foreach (var result in results)
{
validationResult.AddError(propertyName, result.ErrorMessage);
}

return validationResult;
}

public async Task<bool> ValidateAsync()
{
_validationErrors.Clear();

if (_validator != null)
{
var result = await _validator.ValidateAsync(this);

foreach (var error in result.Errors)
{
if (!_validationErrors.ContainsKey(error.Field))
{
_validationErrors[error.Field] = new List<ValidationError>();
}

_validationErrors[error.Field].Add(error);
}

// Notify all properties
OnPropertyChanged(string.Empty);

return result.IsValid;
}

return !HasErrors;
}
}

Validation UI Components

Visual validation feedback components:

<!-- Validation Template -->
<ControlTemplate x:Key="ValidationTemplate">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>

<Border Grid.Column="0"
BorderBrush="Red"
BorderThickness="1">
<AdornedElementPlaceholder/>
</Border>

<Border Grid.Column="1"
Background="Red"
CornerRadius="10"
Width="20" Height="20"
Margin="2,0,0,0"
ToolTip="{Binding [0].ErrorContent}">
<TextBlock Text="!"
Foreground="White"
FontWeight="Bold"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
</Grid>
</ControlTemplate>

<!-- Field with validation -->
<StackPanel>
<Label Content="Customer Code:"/>
<TextBox Text="{Binding Customer,
ValidatesOnDataErrors=True,
UpdateSourceTrigger=PropertyChanged}"
Validation.ErrorTemplate="{StaticResource ValidationTemplate}">
<TextBox.Style>
<Style TargetType="TextBox">
<Style.Triggers>
<Trigger Property="Validation.HasError" Value="True">
<Setter Property="Background" Value="#FFFFE0"/>
</Trigger>
</Style.Triggers>
</Style>
</TextBox.Style>
</TextBox>

<!-- Inline error message -->
<ItemsControl ItemsSource="{Binding (Validation.Errors),
ElementName=CustomerTextBox}"
Margin="0,2,0,0">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding ErrorContent}"
Foreground="Red"
FontSize="11"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>

<!-- Validation Summary -->
<Border Background="#FFFFE0"
BorderBrush="Red"
BorderThickness="1"
Padding="10"
Margin="5"
Visibility="{Binding HasErrors,
Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel>
<TextBlock Text="Please correct the following errors:"
FontWeight="Bold"
Foreground="Red"
Margin="0,0,0,5"/>

<ItemsControl ItemsSource="{Binding ValidationErrors}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Margin="10,2,0,2">
<TextBlock.Text>
<MultiBinding StringFormat="• {0}: {1}">
<Binding Path="Field"/>
<Binding Path="Message"/>
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>

Custom Validation Rules

Domain-specific validation rules:

public class SysproCustomerCodeRule : IValidationRule<string>
{
public string RuleName => "SysproCustomerCode";
public string ErrorMessage => "Invalid SYSPRO customer code format";
public ValidationSeverity Severity => ValidationSeverity.Error;

public bool Validate(string customerCode)
{
if (string.IsNullOrWhiteSpace(customerCode))
return false;

// SYSPRO customer codes:
// - Max 15 characters
// - Uppercase alphanumeric only
// - No special characters except dash
// - Cannot start with number

if (customerCode.Length > 15)
return false;

if (!Regex.IsMatch(customerCode, @"^[A-Z][A-Z0-9\-]{0,14}$"))
return false;

// Cannot have consecutive dashes
if (customerCode.Contains("--"))
return false;

return true;
}

public Task<bool> ValidateAsync(string entity)
{
return Task.FromResult(Validate(entity));
}
}

public class DuplicatePaymentRule : IValidationRule<ArReversePaymentHeader>
{
private readonly IArReversePaymentService _service;

public string RuleName => "DuplicatePayment";
public string ErrorMessage { get; set; }
public ValidationSeverity Severity => ValidationSeverity.Error;

public DuplicatePaymentRule(IArReversePaymentService service)
{
_service = service;
}

public bool Validate(ArReversePaymentHeader payment)
{
return Task.Run(() => ValidateAsync(payment)).Result;
}

public async Task<bool> ValidateAsync(ArReversePaymentHeader payment)
{
var isDuplicate = await _service.IsDuplicatePayment(payment);

if (isDuplicate)
{
ErrorMessage = $"Payment {payment.CheckNumber} for customer {payment.Customer} " +
"has already been queued or reversed";
return false;
}

return true;
}
}

Testing the Validation Framework

[TestClass]
public class ValidationFrameworkTests
{
[TestMethod]
public async Task PaymentValidator_InvalidCustomer_ReturnsError()
{
// Arrange
var payment = new ArReversePaymentHeader
{
Customer = "INVALID_CUSTOMER_CODE_TOO_LONG",
CheckNumber = "12345"
};

var validator = new PaymentReversalValidator(_logger, _service);

// Act
var result = await validator.ValidateAsync(payment);

// Assert
Assert.IsFalse(result.IsValid);
Assert.IsTrue(result.Errors.Any(e => e.Field == "Customer"));
}
}

Benefits

  1. Error Prevention: Catches issues before they reach SYSPRO
  2. User Experience: Immediate feedback improves data quality
  3. Maintainability: Centralized validation logic
  4. Flexibility: Easy to add new rules
  5. Performance: Async validation for heavy checks

Summary

The custom validation framework provides a robust, extensible system for ensuring data quality throughout the AR Payment Reversal dashboard. Through multiple validation layers, fluent configuration, and clear user feedback, the framework prevents errors while maintaining excellent performance and user experience.