Business Logic Services
Overview
The business logic services layer implements the core domain logic for the AR Payment Reversal dashboard. This document details the ArReversePaymentService and related services that orchestrate complex business workflows, implement validation rules, and manage the payment reversal process from queue management through posting to SYSPRO.
Key Concepts
- Payment Reversal Workflow: Multi-step process for reversing AR payments
- Queue Management: Staging area for payments pending reversal
- Business Rule Validation: Domain-specific validation logic
- Workflow Orchestration: Coordinating multiple operations
- Post-Processing: Handling completion and generating reports
- Error Recovery: Business-level error handling and compensation
Implementation Details
Core Business Service
ArReversePaymentService Implementation
// MepApps.Dash.Ar.Maint.PaymentReversal/Services/ArReversePaymentService.cs (Lines 25-36)
public class ArReversePaymentService : IArReversePaymentService
{
private readonly ILogger<ArReversePaymentService> _logger;
private readonly ISysproPostService _sysproPostService;
private readonly IExcelExportService _excelExport;
public ArReversePaymentService(
ILogger<ArReversePaymentService> logger,
ISysproPostService sysproPostService,
IExcelExportService excelExport)
{
_logger = logger;
_sysproPostService = sysproPostService;
_excelExport = excelExport;
}
}
Payment Reversal Workflow
The complete payment reversal process involves multiple coordinated steps:
Step 1: Queue Management
// MepApps.Dash.Ar.Maint.PaymentReversal/Services/ArReversePaymentService.cs (Lines 421-473)
public async Task AddToPaymentsQueueHeaders(
string customer,
string checkNumber,
decimal checkValue,
DateTime paymentDate,
string bank,
decimal trnYear,
decimal trnMonth,
decimal journal)
{
try
{
using (PluginSysproDataContext context =
MainView.MepPluginServiceProvider.GetService<PluginSysproDataContext>())
{
// Business Rule: Prevent duplicate queue entries
var isDuplicate = await context.C_ArReversePaymentQueueHeader
.AnyAsync(x =>
x.Customer == customer
&& x.CheckNumber == checkNumber
&& x.TrnYear == trnYear
&& x.TrnMonth == trnMonth
&& x.Journal == journal);
if (!isDuplicate)
{
var queueHeader = new CG_ArReversePaymentQueueHeader
{
Customer = customer,
CheckNumber = checkNumber,
CheckValue = checkValue,
PaymentDate = paymentDate,
Bank = bank,
TrnYear = trnYear,
TrnMonth = trnMonth,
Journal = journal
};
context.C_ArReversePaymentQueueHeader.Add(queueHeader);
await context.SaveChangesAsync().ConfigureAwait(true);
_logger.LogInformation("Payment queued for reversal. {@PaymentQueueContext}",
new { customer, checkNumber, checkValue });
}
else
{
_logger.LogWarning("Duplicate payment queue entry prevented. {@DuplicateContext}",
new { customer, checkNumber, trnYear, trnMonth, journal });
}
}
}
catch (DbEntityValidationException ex)
{
LogValidationErrors(ex);
throw;
}
}
Step 2: Payment Validation
// MepApps.Dash.Ar.Maint.PaymentReversal/Services/ArReversePaymentService.cs (Lines 244-341)
public async Task<IEnumerable<ArReversePaymentHeader>> QueryCustomerPaymentsAsync(string customer)
{
try
{
using (var context = MainView.MepPluginServiceProvider.GetService<PluginSysproDataContext>())
{
// Business Rule: Only reversible payments
// - Must have invoice applications
// - Invoice balance must not equal original value (partially paid)
// - Transaction value must be non-zero
var eligiblePayments = await (
from p in context.ArInvoicePays
join i in context.ArInvoices
on new { p.Customer, p.Invoice } equals new { i.Customer, i.Invoice }
where p.Customer == customer
&& i.InvoiceBal1 != i.CurrencyValue // Has remaining balance
&& p.TrnValue != 0 // Has actual payment value
group p by new { p.Customer, p.Reference, p.TrnYear, p.TrnMonth, p.Journal }
into paymentGroup
where paymentGroup.Sum(x => x.TrnValue) != 0 // Net value is non-zero
select paymentGroup.Key
).ToListAsync();
// Build complete payment headers with additional details
var paymentHeaders = new List<ArReversePaymentHeader>();
foreach (var payment in eligiblePayments)
{
var header = await BuildPaymentHeader(payment);
if (ValidatePaymentEligibility(header))
{
paymentHeaders.Add(header);
}
}
return paymentHeaders.OrderByDescending(x => x.PaymentDate);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error querying customer payments for {Customer}", customer);
throw;
}
}
private bool ValidatePaymentEligibility(ArReversePaymentHeader header)
{
// Business Rules for payment eligibility
if (header.CheckValue <= 0)
{
_logger.LogWarning("Payment {CheckNumber} has invalid value {CheckValue}",
header.CheckNumber, header.CheckValue);
return false;
}
if (header.PaymentDate > DateTime.Now)
{
_logger.LogWarning("Payment {CheckNumber} has future date {PaymentDate}",
header.CheckNumber, header.PaymentDate);
return false;
}
// Check if payment is already in reversal queue
if (IsPaymentQueued(header))
{
_logger.LogDebug("Payment {CheckNumber} already queued for reversal",
header.CheckNumber);
return false;
}
return true;
}
Step 3: Invoice Matching
// MepApps.Dash.Ar.Maint.PaymentReversal/Services/ArReversePaymentService.cs (Lines 343-396)
private IQueryable<ArReversePaymentDetail> GetPaymentInvoicesQueryable(
PluginSysproDataContext pluginSysproDataContext)
{
try
{
var queryable = (
from jnlCtrl in pluginSysproDataContext.ArCshJnlCtls
join jnlDet in pluginSysproDataContext.ArCshJnlDets
on new { jnlCtrl.TrnYear, jnlCtrl.TrnMonth, jnlCtrl.Journal }
equals new { jnlDet.TrnYear, jnlDet.TrnMonth, jnlDet.Journal }
join jnlPay in pluginSysproDataContext.ArCshJnlPays
on new { jnlDet.TrnMonth, jnlDet.TrnYear, jnlDet.Journal, jnlDet.EntryNumber }
equals new { jnlPay.TrnMonth, jnlPay.TrnYear, jnlPay.Journal, jnlPay.EntryNumber }
join pay in pluginSysproDataContext.ArInvoicePays
on new { Invoice = jnlPay.PayInvoice, jnlDet.Customer, jnlDet.Reference }
equals new { pay.Invoice, pay.Customer, pay.Reference }
join i in pluginSysproDataContext.ArInvoices
on new { pay.Invoice, pay.Customer }
equals new { i.Invoice, i.Customer }
// Business Rule: Only include invoices with outstanding balance
where i.InvoiceBal1 != i.CurrencyValue
select new ArReversePaymentDetail
{
Customer = jnlDet.Customer,
CustomerName = c.Name,
CheckNumber = jnlDet.Reference,
SalesOrder = i.SalesOrder,
Invoice = jnlPay.PayInvoice,
InvoiceDate = i.InvoiceDate,
OrigAmount = i.CurrencyValue,
Balance = i.InvoiceBal1,
PaymentValue = (-1 * (pay.TrnValue + pay.DiscValue)), // Negate for reversal
DiscountValue = pay.DiscValue,
Bank = jnlCtrl.CashAccBank,
DocumentType = i.DocumentType,
TrnYear = jnlCtrl.TrnYear,
TrnMonth = jnlCtrl.TrnMonth,
Journal = jnlCtrl.Journal,
EntryNumber = jnlDet.EntryNumber
}).Distinct();
return queryable;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error building payment invoices queryable");
throw;
}
}
Step 4: Reversal Execution
// MepApps.Dash.Ar.Maint.PaymentReversal/Services/ArReversePaymentService.cs (Lines 475-498)
public async Task<ArReversePaymentPostCompletion> ReversePaymentsAsync(
IEnumerable<ArReversePaymentHeader> checks,
IEnumerable<ArReversePaymentDetail> invoices,
string postPeriod)
{
ArReversePaymentPostCompletion completionObject = null;
try
{
// Validation
if (checks == null || !checks.Any())
{
_logger.LogWarning("No checks provided for reversal");
return null;
}
if (invoices == null || !invoices.Any())
{
_logger.LogWarning("No invoices provided for reversal");
return null;
}
// Business Rule: Validate posting period
if (!await ValidatePostingPeriod(postPeriod))
{
throw new BusinessRuleException($"Invalid posting period: {postPeriod}");
}
// Business Rule: Validate customer balances
foreach (var check in checks)
{
if (!await ValidateCustomerBalance(check.Customer, check.CheckValue.Value))
{
throw new BusinessRuleException(
$"Customer {check.Customer} balance validation failed");
}
}
// Orchestrate the reversal process
_logger.LogInformation("Starting payment reversal. Checks: {CheckCount}, Invoices: {InvoiceCount}",
checks.Count(), invoices.Count());
string inputXml = _sysproPostService.GetInputXml(checks, invoices);
string paramXml = _sysproPostService.GetParamXml(postPeriod);
// Execute SYSPRO posting
string outputXml = _sysproPostService.PerformBusinessObjectPost(inputXml, paramXml);
// Build completion object
completionObject = await BuildCompletionObjectAsync(inputXml, paramXml, outputXml);
// Post-processing
if (completionObject.PostSucceeded)
{
await RecordCompletionHistory(completionObject);
await RemoveFromQueue(checks);
}
_logger.LogInformation("Payment reversal completed. Success: {Success}, ItemsProcessed: {Items}",
completionObject.PostSucceeded, completionObject.ItemsProcessed);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in ReversePaymentsAsync");
throw;
}
return completionObject;
}
Business Rule Implementation
Complex Business Validation
public class PaymentReversalValidator
{
private readonly ILogger<PaymentReversalValidator> _logger;
public async Task<ValidationResult> ValidateReversalRequest(
ArReversePaymentHeader payment,
IEnumerable<ArReversePaymentDetail> invoices)
{
var errors = new List<string>();
// Rule 1: Payment must balance with invoice applications
var totalInvoiceAmount = invoices.Sum(i => i.PaymentValue + i.DiscountValue);
if (Math.Abs(payment.CheckValue.Value - Math.Abs(totalInvoiceAmount)) > 0.01m)
{
errors.Add($"Payment amount {payment.CheckValue} does not match " +
$"invoice applications {totalInvoiceAmount}");
}
// Rule 2: All invoices must belong to the same customer
var customers = invoices.Select(i => i.Customer).Distinct();
if (customers.Count() > 1)
{
errors.Add("Invoices belong to multiple customers");
}
// Rule 3: Check for period-end restrictions
if (await IsPeriodLocked(payment.TrnYear, payment.TrnMonth))
{
errors.Add($"Period {payment.TrnMonth}/{payment.TrnYear} is locked");
}
// Rule 4: Validate document types
var invalidDocs = invoices.Where(i => !IsReversibleDocumentType(i.DocumentType));
if (invalidDocs.Any())
{
errors.Add($"Non-reversible document types found: " +
$"{string.Join(", ", invalidDocs.Select(d => d.DocumentType).Distinct())}");
}
if (errors.Any())
{
_logger.LogWarning("Validation failed for payment reversal. Errors: {@ValidationErrors}",
errors);
return new ValidationResult { IsValid = false, Errors = errors };
}
return new ValidationResult { IsValid = true };
}
private bool IsReversibleDocumentType(string documentType)
{
// Business Rule: Only certain document types can be reversed
var reversibleTypes = new[] { "I", "C", "D" }; // Invoice, Credit, Debit
return reversibleTypes.Contains(documentType);
}
}
Workflow Orchestration
Multi-Step Process Coordination
public class PaymentReversalWorkflow
{
private readonly IArReversePaymentService _paymentService;
private readonly ISysproPostService _postService;
private readonly ILogger<PaymentReversalWorkflow> _logger;
public async Task<WorkflowResult> ExecuteReversalWorkflow(
IEnumerable<ArReversePaymentHeader> payments,
string postingPeriod)
{
var workflow = new WorkflowBuilder()
.AddStep("Validate", async () => await ValidatePayments(payments))
.AddStep("LoadInvoices", async () => await LoadInvoiceDetails(payments))
.AddStep("PreparePosting", async () => await PreparePostingDocuments(payments))
.AddStep("PostToSyspro", async () => await PostToSyspro(postingPeriod))
.AddStep("ProcessResults", async () => await ProcessPostingResults())
.AddStep("Cleanup", async () => await CleanupQueue(payments))
.OnError(async (step, error) => await HandleWorkflowError(step, error))
.Build();
return await workflow.ExecuteAsync();
}
private async Task<StepResult> ValidatePayments(IEnumerable<ArReversePaymentHeader> payments)
{
_logger.LogInformation("Validating {Count} payments for reversal", payments.Count());
foreach (var payment in payments)
{
var validationResult = await ValidatePayment(payment);
if (!validationResult.IsValid)
{
return StepResult.Failure($"Payment {payment.CheckNumber} validation failed");
}
}
return StepResult.Success();
}
}
Error Handling and Recovery
Compensation Logic
public class ReversalCompensationService
{
private readonly ILogger<ReversalCompensationService> _logger;
private readonly IArReversePaymentService _service;
public async Task CompensateFailedReversal(
ArReversePaymentPostCompletion failedCompletion,
IEnumerable<ArReversePaymentHeader> originalPayments)
{
_logger.LogWarning("Starting compensation for failed reversal");
try
{
// Step 1: Identify what was successfully processed
var processedPayments = failedCompletion.Payments
.Where(p => p.Journal.HasValue && p.Journal > 0)
.ToList();
if (processedPayments.Any())
{
// Step 2: Reverse the successful postings
_logger.LogInformation("Reversing {Count} successful postings",
processedPayments.Count);
foreach (var payment in processedPayments)
{
await ReverseSuccessfulPosting(payment);
}
}
// Step 3: Restore queue state
foreach (var originalPayment in originalPayments)
{
if (!processedPayments.Any(p =>
p.Customer == originalPayment.Customer &&
p.CheckNumber == originalPayment.CheckNumber))
{
// Payment was not processed, keep in queue
_logger.LogDebug("Keeping unprocessed payment {CheckNumber} in queue",
originalPayment.CheckNumber);
}
}
// Step 4: Notify users
await NotifyCompensationComplete(originalPayments, processedPayments);
}
catch (Exception ex)
{
_logger.LogError(ex, "Critical error during compensation");
throw new CompensationFailedException("Unable to compensate failed reversal", ex);
}
}
}
Performance Optimization
Batch Processing
public class BatchReversalService
{
private readonly int _batchSize = 50;
private readonly ILogger<BatchReversalService> _logger;
public async Task<BatchProcessingResult> ProcessReversalsInBatches(
IEnumerable<ArReversePaymentHeader> allPayments,
string postingPeriod)
{
var results = new BatchProcessingResult();
var batches = allPayments.Chunk(_batchSize);
foreach (var batch in batches.Select((value, index) => new { value, index }))
{
_logger.LogInformation("Processing batch {BatchNumber} of {TotalBatches}",
batch.index + 1, batches.Count());
try
{
var batchResult = await ProcessBatch(batch.value, postingPeriod);
results.SuccessfulBatches.Add(batchResult);
}
catch (Exception ex)
{
_logger.LogError(ex, "Batch {BatchNumber} failed", batch.index + 1);
results.FailedBatches.Add(new FailedBatch
{
BatchNumber = batch.index + 1,
Payments = batch.value,
Error = ex.Message
});
if (!ContinueOnBatchFailure())
break;
}
}
return results;
}
}
Service Method Naming Conventions
The service follows consistent naming patterns:
- Query methods:
QueryXxxAsync- Returns data without modification - Get methods:
GetXxxAsync- Retrieves single items or collections - Add methods:
AddXxxAsync- Creates new records - Delete methods:
DeleteXxxAsync- Removes records - Update methods:
UpdateXxxAsync- Modifies existing records - Process methods:
ProcessXxxAsync- Executes business workflows - Validate methods:
ValidateXxx- Performs validation - Build methods:
BuildXxx- Constructs complex objects
Best Practices
- Validate early - Check business rules before expensive operations
- Use transactions - Ensure data consistency
- Log business events - Track important domain activities
- Handle partial failures - Implement compensation logic
- Optimize for batches - Process collections efficiently
- Cache business data - Reduce repeated calculations
- Document business rules - Make logic explicit
Common Pitfalls
- Missing validation - Not checking business rules
- No compensation - Unable to recover from failures
- Synchronous processing - Blocking on long operations
- Poor error messages - Not explaining business violations
- Tight coupling - Business logic mixed with infrastructure
Related Documentation
- Service Architecture - Overall service design
- Data Services - Data access layer
- Integration Services - External integrations
- Utility Services - Helper services
Summary
The business logic services layer implements the core domain logic for payment reversals, providing a robust framework for managing complex workflows, enforcing business rules, and handling error scenarios. Through proper separation of concerns and workflow orchestration, the services ensure reliable and maintainable business operations.