Skip to main content

Example: Payment Reversal Workflow

Overview

This example demonstrates the complete payment reversal workflow implementation in the AR Payment Reversal dashboard, showcasing how multiple payments are queued, validated, and reversed through SYSPRO integration. This workflow represents the core business logic of the application, demonstrating complex orchestration between UI, services, and SYSPRO business objects.

Business Context

Payment reversals are critical financial operations that occur when:

  • Checks are returned for insufficient funds (NSF)
  • Duplicate payments are discovered
  • Payment errors need correction
  • Customer disputes require reversal

The workflow ensures data integrity, provides audit trails, and maintains synchronization with SYSPRO's AR module while preventing common errors through comprehensive validation.

Implementation Details

Step 1: Queue Management

The workflow begins with adding payments to a reversal queue, allowing batch processing and review before posting to SYSPRO.

// MepApps.Dash.Ar.Maint.PaymentReversal/ViewModels/ArReversePaymentAddPaymentViewModel.cs (Lines 321-337)
private async Task AddToPaymentQueue()
{
try
{
_logger.LogInformation("Adding {Count} payments to reversal queue",
SelectedCustomerPayments.Count());

// Validate selected payments
var validationResult = ValidatePaymentsForQueue();
if (!validationResult.IsValid)
{
ShowValidationErrors(validationResult.Errors);
return;
}

// Prepare payment data
var paymentsToQueue = SelectedCustomerPayments
.Where(p => p.Selected && p.SelectEnabled)
.Select(payment => new ArReversePaymentHeader
{
Customer = payment.Customer,
CheckNumber = payment.CheckNumber,
CheckValue = payment.CheckValue,
PaymentDate = payment.PaymentDate,
Bank = payment.Bank,
TrnYear = payment.TrnYear,
TrnMonth = payment.TrnMonth,
Journal = payment.Journal,
// Track who queued and when
QueuedBy = GetCurrentUser(),
QueuedDate = DateTime.Now,
Status = "Pending"
})
.ToList();

// Add to queue via service
var result = await _service.AddPaymentsToQueue(paymentsToQueue);

if (result.Success)
{
_logger.LogInformation("Successfully added {Count} payments to queue",
result.ItemsAdded);

// Raise completion event
AddPaymentCompletedEvent?.Invoke(this, new PaymentQueuedEventArgs
{
PaymentsAdded = result.ItemsAdded,
TotalAmount = paymentsToQueue.Sum(p => p.CheckValue ?? 0)
});

// Navigate back to queue view
NavigateBackToQueue();
}
else
{
_logger.LogWarning("Failed to add payments to queue: {Reason}", result.ErrorMessage);
ShowError($"Failed to add payments: {result.ErrorMessage}");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error adding payments to queue. {@AddQueueContext}",
new { paymentCount = SelectedCustomerPayments?.Count() });
ShowError("An error occurred while adding payments to the queue.");
}
}

Step 2: Service Layer Processing

The service layer handles database operations and business logic validation.

// MepApps.Dash.Ar.Maint.PaymentReversal/Services/ArReversePaymentService.cs (Lines 130-173)
public async Task<QueueOperationResult> AddPaymentsToQueue(
IEnumerable<ArReversePaymentHeader> payments)
{
var result = new QueueOperationResult();

using (var context = _serviceProvider.GetService<PluginSysproDataContext>())
using (var transaction = context.Database.BeginTransaction())
{
try
{
foreach (var payment in payments)
{
// Check for duplicates
var isDuplicate = await context.CG_ArReversePaymentQueueHeaders
.AnyAsync(q => q.Customer == payment.Customer
&& q.CheckNumber == payment.CheckNumber
&& q.TrnYear == payment.TrnYear
&& q.TrnMonth == payment.TrnMonth
&& q.Journal == payment.Journal);

if (isDuplicate)
{
_logger.LogWarning("Payment already in queue: {Customer} - {CheckNumber}",
payment.Customer, payment.CheckNumber);
result.SkippedItems++;
continue;
}

// Create queue entry
var queueEntry = new CG_ArReversePaymentQueueHeader
{
Customer = payment.Customer,
CheckNumber = payment.CheckNumber,
CheckValue = payment.CheckValue,
PaymentDate = payment.PaymentDate,
Bank = payment.Bank,
TrnYear = payment.TrnYear,
TrnMonth = payment.TrnMonth,
Journal = payment.Journal,
CreatedDate = DateTime.Now,
CreatedBy = payment.QueuedBy,
QueueStatus = "Pending"
};

context.CG_ArReversePaymentQueueHeaders.Add(queueEntry);
result.ItemsAdded++;
}

await context.SaveChangesAsync();
transaction.Commit();

result.Success = true;
_logger.LogInformation("Added {Count} payments to queue, {Skipped} skipped",
result.ItemsAdded, result.SkippedItems);
}
catch (Exception ex)
{
transaction.Rollback();
_logger.LogError(ex, "Error adding payments to queue");
result.Success = false;
result.ErrorMessage = "Database error occurred";
throw;
}
}

return result;
}

Step 3: Validation Before Posting

Comprehensive validation ensures data integrity before SYSPRO posting.

// MepApps.Dash.Ar.Maint.PaymentReversal/Services/ArReversePaymentService.cs
private async Task<ValidationResult> ValidatePaymentsForReversal(
IEnumerable<ArReversePaymentHeader> payments,
string postPeriod)
{
var errors = new List<string>();

// Validate posting period
using (var context = _serviceProvider.GetService<PluginSysproDataContext>())
{
// Check period is open
var periodOpen = await context.Database
.SqlQuery<bool>(@"
SELECT CASE WHEN ArPeriodStatus = 'O' THEN 1 ELSE 0 END
FROM ArControl
WHERE ArPeriod = @p0", postPeriod)
.FirstOrDefaultAsync();

if (!periodOpen)
{
errors.Add($"Period {postPeriod} is not open for posting");
}

// Validate each payment
foreach (var payment in payments)
{
// Check customer status
var customer = await context.ArCustomers
.FirstOrDefaultAsync(c => c.Customer == payment.Customer);

if (customer == null)
{
errors.Add($"Customer {payment.Customer} not found");
continue;
}

if (customer.CustomerOnHold == "Y")
{
errors.Add($"Customer {payment.Customer} is on hold");
}

// Verify payment exists in history
var originalPayment = await context.ArPayHistories
.FirstOrDefaultAsync(p =>
p.Customer == payment.Customer &&
p.Reference == payment.CheckNumber &&
p.PaymYear == payment.TrnYear &&
p.PaymMonth == payment.TrnMonth &&
p.CashJournal == payment.Journal);

if (originalPayment == null)
{
errors.Add($"Original payment not found: {payment.CheckNumber}");
}

// Check for previous reversals
var previousReversal = await context.CG_ArReversePaymentPostCompletionHistories
.AnyAsync(h =>
h.Customer == payment.Customer &&
h.CheckNumber == payment.CheckNumber &&
h.PostSucceeded == true);

if (previousReversal)
{
errors.Add($"Payment {payment.CheckNumber} has already been reversed");
}
}
}

return new ValidationResult
{
IsValid = !errors.Any(),
Errors = errors
};
}

Step 4: SYSPRO Posting

The actual reversal is posted to SYSPRO using the ARSTPY business object.

// 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
{
_logger.LogInformation("Starting payment reversal for {Count} payments", checks.Count());

// Step 1: Validate
var validationResult = await ValidatePaymentsForReversal(checks, postPeriod);
if (!validationResult.IsValid)
{
throw new ValidationException(string.Join("; ", validationResult.Errors));
}

// Step 2: Build XML for SYSPRO
string inputXml = _sysproPostService.GetInputXml(checks, invoices);
string paramXml = _sysproPostService.GetParamXml(postPeriod);

_logger.LogDebug("Generated input XML: {InputXml}", inputXml);
_logger.LogDebug("Generated param XML: {ParamXml}", paramXml);

// Step 3: Execute SYSPRO post
string outputXml = _sysproPostService.PerformBusinessObjectPost(inputXml, paramXml);

// Step 4: Parse results
completionObject = _sysproPostService.ParseXmlOutput(outputXml);
completionObject.InputXml = inputXml;
completionObject.ParamXml = paramXml;
completionObject.OutputXml = outputXml;
completionObject.PostDate = DateTime.Now;
completionObject.PostPeriod = postPeriod;

// Step 5: Process results
if (completionObject.PostSucceeded)
{
await HandleSuccessfulReversal(completionObject, checks);
}
else
{
await HandleFailedReversal(completionObject, checks);
}

return completionObject;
}
catch (Exception ex)
{
_logger.LogError(ex, "Critical error in payment reversal. {@ReversalContext}",
new { checkCount = checks?.Count(), postPeriod });

// Create failure completion object
if (completionObject == null)
{
completionObject = new ArReversePaymentPostCompletion
{
PostSucceeded = false,
ErrorMessage = ex.Message,
PostDate = DateTime.Now
};
}

throw;
}
}

Step 5: XML Generation for SYSPRO

Creating properly formatted XML for the SYSPRO business object.

// MepApps.Dash.Ar.Maint.PaymentReversal/Services/SysproPostService.cs (Lines 50-84)
private XElement GetItemXml(
ArReversePaymentHeader check,
IEnumerable<ArReversePaymentDetail> invoices)
{
var item = new XElement("Item");

// Payment header with negative value for reversal
var payment = new XElement("Payment",
new XElement("Customer", check.Customer.Trim()),
new XElement("PaymentValue", (-1 * check.CheckValue.Value).ToString("F2")),
new XElement("Reference", check.CheckNumber.Trim()),
new XElement("PaymentDate", check.PaymentDate.Value.ToString("yyyy-MM-dd")),
new XElement("JournalNotation", $"Reversal of payment {check.CheckNumber}"),
new XElement("PaymentNarration", "Payment reversal"),
new XElement("Bank", check.Bank),
new XElement("PaymentType", "C") // C = Check
);

// Add invoice applications with negative values
foreach (var invoice in invoices)
{
var invoiceXml = new XElement("InvoiceToPay",
new XElement("TransactionType", MapDocumentType(invoice.DocumentType)),
new XElement("Invoice", invoice.Invoice.Trim()),
new XElement("GrossPaymentValue",
(-1 * (invoice.DiscountValue + invoice.PaymentValue)).ToString("F2")),
new XElement("DiscountValue",
(-1 * invoice.DiscountValue).ToString("F2"))
);

payment.Add(invoiceXml);
}

item.Add(payment);
return item;
}

private string MapDocumentType(string docType)
{
return docType switch
{
"I" => "Invoice",
"C" => "Credit",
"D" => "Debit",
_ => "Invoice"
};
}

Step 6: Post-Processing

After successful posting, update records and provide feedback.

private async Task HandleSuccessfulReversal(
ArReversePaymentPostCompletion completion,
IEnumerable<ArReversePaymentHeader> originalPayments)
{
using (var context = _serviceProvider.GetService<PluginSysproDataContext>())
using (var transaction = context.Database.BeginTransaction())
{
try
{
// Record in history
var history = new CG_ArReversePaymentPostCompletionHistory
{
PostDate = completion.PostDate,
PostedBy = GetCurrentUser(),
PostSucceeded = true,
ItemsProcessed = completion.ItemsProcessed,
ItemsInvalid = completion.ItemsInvalid,
JournalCount = completion.JournalCount,
PaymentCount = originalPayments.Count(),
PaymentTotal = originalPayments.Sum(p => p.CheckValue ?? 0),
BusinessObject = "ARSTPY",
InputXml = completion.InputXml,
ParamXml = completion.ParamXml,
OutputXml = completion.OutputXml
};

context.CG_ArReversePaymentPostCompletionHistories.Add(history);

// Remove from queue
var queueItems = await context.CG_ArReversePaymentQueueHeaders
.Where(q => originalPayments.Any(p =>
p.Customer == q.Customer &&
p.CheckNumber == q.CheckNumber))
.ToListAsync();

context.CG_ArReversePaymentQueueHeaders.RemoveRange(queueItems);

await context.SaveChangesAsync();
transaction.Commit();

_logger.LogInformation("Successfully processed reversal. Journals created: {Journals}",
completion.JournalCount);
}
catch (Exception ex)
{
transaction.Rollback();
_logger.LogError(ex, "Error in post-processing");
throw;
}
}
}

Step 7: User Feedback

Providing clear feedback to users about the operation results.

// MepApps.Dash.Ar.Maint.PaymentReversal/ViewModels/ArReversePaymentCompletionViewModel.cs
public class ArReversePaymentCompletionViewModel : BaseRouteableViewModel
{
public void DisplayCompletionResults(ArReversePaymentPostCompletion completion)
{
PostCompletion = completion;

if (completion.PostSucceeded)
{
StatusMessage = "Payment reversals posted successfully";
StatusColor = Brushes.Green;

// Build summary
var summary = new StringBuilder();
summary.AppendLine($"✓ {completion.ItemsProcessed} payments reversed");
summary.AppendLine($"✓ {completion.JournalCount} journals created");
summary.AppendLine($"✓ Total amount: {completion.PaymentTotal:C}");

if (completion.ItemsInvalid > 0)
{
summary.AppendLine($"⚠ {completion.ItemsInvalid} items skipped");
}

SummaryText = summary.ToString();
}
else
{
StatusMessage = "Payment reversal failed";
StatusColor = Brushes.Red;

// Show error details
ErrorDetails = ParseErrorDetails(completion.OutputXml);
}

// Enable export if successful
ExportEnabled = completion.PostSucceeded && completion.ItemsProcessed > 0;
}
}

Error Handling

The workflow includes comprehensive error handling at each step:

  1. Validation Errors: Caught before posting and displayed to user
  2. Database Errors: Rolled back with transaction support
  3. SYSPRO Errors: Parsed from XML response and logged
  4. Network Errors: Retry logic with exponential backoff
  5. Unexpected Errors: Logged with full context for debugging

Performance Considerations

  • Batch Processing: Queue allows batch reversals for efficiency
  • Async Operations: All I/O operations are asynchronous
  • Transaction Scope: Database operations use transactions
  • Validation Caching: Customer data cached during validation
  • Progress Feedback: UI updated during long operations

Testing Approach

[TestClass]
public class PaymentReversalWorkflowTests
{
[TestMethod]
public async Task ReversePayments_ValidPayments_Success()
{
// Arrange
var payments = CreateTestPayments();
var mockService = new Mock<IArReversePaymentService>();
mockService.Setup(s => s.ReversePaymentsAsync(It.IsAny<IEnumerable<ArReversePaymentHeader>>(),
It.IsAny<IEnumerable<ArReversePaymentDetail>>(),
It.IsAny<string>()))
.ReturnsAsync(CreateSuccessfulCompletion());

// Act
var result = await mockService.Object.ReversePaymentsAsync(payments, invoices, "202401");

// Assert
Assert.IsTrue(result.PostSucceeded);
Assert.AreEqual(payments.Count(), result.ItemsProcessed);
}
}

Key Takeaways

  1. Separation of Concerns: Each layer has specific responsibilities
  2. Validation First: Comprehensive validation prevents errors
  3. Audit Trail: Every action is logged and recorded
  4. Transaction Safety: Database operations are atomic
  5. User Feedback: Clear communication of results and errors
  6. SYSPRO Integration: Proper XML formatting and error handling

Summary

This payment reversal workflow demonstrates a complete end-to-end implementation of a critical business process. Through careful orchestration of UI, services, and SYSPRO integration, the workflow ensures data integrity while providing a smooth user experience. The implementation showcases best practices in error handling, validation, and transaction management that can be applied to other financial operations within the dashboard.