Skip to main content

Integration Services

Overview

The integration services layer manages all external system communications, primarily with SYSPRO ERP through the ENet API. This document details the SysproPostService implementation, API communication patterns, authentication handling, retry mechanisms, data transformation, and error mapping strategies that enable reliable integration with external systems.

Key Concepts

  • SYSPRO Business Object Integration: ARSTPY payment posting
  • XML-based Communication: Input/output XML document handling
  • ENet API Wrapper: Abstraction over SYSPRO's ENet interface
  • Transaction Posting: Reliable posting to SYSPRO journals
  • Response Parsing: Structured handling of SYSPRO responses
  • Circuit Breaker Pattern: Resilience against integration failures

Implementation Details

SysproPostService Architecture

Core Service Implementation

// MepApps.Dash.Ar.Maint.PaymentReversal/Services/SysproPostService.cs (Lines 16-27)
public class SysproPostService : ISysproPostService
{
private readonly ILogger<SysproPostService> _logger;
private readonly ISysproEnet _sysproEnet;
private readonly Dispatcher _dispatcher;

public SysproPostService(
ILogger<SysproPostService> logger,
Dispatcher dispatcher,
ISysproEnet sysproEnet)
{
_logger = logger;
_dispatcher = dispatcher;
_sysproEnet = sysproEnet;
}
}

XML Document Generation

Input XML Construction

// MepApps.Dash.Ar.Maint.PaymentReversal/Services/SysproPostService.cs (Lines 29-48)
public string GetInputXml(
IEnumerable<ArReversePaymentHeader> checks,
IEnumerable<ArReversePaymentDetail> invoices)
{
string inputXml = string.Empty;
try
{
var xmlBuilder = new StringBuilder();
xmlBuilder.Append("<PostArPayment>");

foreach (ArReversePaymentHeader check in checks)
{
// Get matching invoices for this check
var checkInvoices = invoices.Where(x =>
x.Customer == check.Customer &&
x.CheckNumber == check.CheckNumber &&
x.TrnYear == check.TrnYear &&
x.TrnMonth == check.TrnMonth &&
x.Journal == check.Journal);

xmlBuilder.Append(GetItemXml(check, checkInvoices));
}

xmlBuilder.Append("</PostArPayment>");
inputXml = xmlBuilder.ToString();

_logger.LogDebug("Generated input XML for {CheckCount} payments", checks.Count());

if (_logger.IsEnabled(LogLevel.Trace))
{
_logger.LogTrace("Input XML: {InputXml}", inputXml);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating input XML");
throw;
}
return inputXml;
}

Payment Item XML Structure

// MepApps.Dash.Ar.Maint.PaymentReversal/Services/SysproPostService.cs (Lines 50-84)
public string GetItemXml(
ArReversePaymentHeader check,
IEnumerable<ArReversePaymentDetail> invoices)
{
string itemXml = string.Empty;
try
{
if (invoices.Any())
{
var xml = new XElement("Item",
new XElement("Payment",
new XElement("Customer", check.Customer),
new XElement("PaymentValue", (-1 * check.CheckValue.Value).ToString("0.00#")),
new XElement("Reference", check.CheckNumber.Trim()),
new XElement("PaymentDate", check.PaymentDate.Value.ToString("yyyy-MM-dd")),
new XElement("JournalNotation", string.Empty),
new XElement("PaymentNarration", $"Reversal of payment {check.CheckNumber}"),
new XElement("Bank", check.Bank),
new XElement("PaymentType", "C"), // C = Check payment

// Add invoice applications
invoices.Select(invoice => GetInvoiceXmlElement(invoice))
)
);

itemXml = xml.ToString();

_logger.LogDebug("Generated XML for payment {CheckNumber} with {InvoiceCount} invoices",
check.CheckNumber, invoices.Count());
}
else
{
_logger.LogWarning("No invoices found for payment {CheckNumber}", check.CheckNumber);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating item XML for check {CheckNumber}", check.CheckNumber);
throw;
}
return itemXml;
}

Invoice Application XML

// MepApps.Dash.Ar.Maint.PaymentReversal/Services/SysproPostService.cs (Lines 86-103)
public string GetInvoiceXml(ArReversePaymentDetail invoice)
{
string invoiceXml = string.Empty;
try
{
var xml = new XElement("InvoiceToPay",
new XElement("TransactionType", invoice.DocumentType),
new XElement("Invoice", invoice.Invoice),
new XElement("GrossPaymentValue",
(-1 * (invoice.DiscountValue + invoice.PaymentValue)).ToString("0.00#")),
new XElement("DiscountValue",
(-1 * invoice.DiscountValue).ToString("0.00#"))
);

invoiceXml = xml.ToString();

_logger.LogTrace("Generated invoice XML for {Invoice}: {InvoiceXml}",
invoice.Invoice, invoiceXml);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating invoice XML for {Invoice}", invoice.Invoice);
throw;
}
return invoiceXml;
}

Parameter XML Configuration

// MepApps.Dash.Ar.Maint.PaymentReversal/Services/SysproPostService.cs (Lines 105-127)
public string GetParamXml(string postPeriod)
{
var paramXml = new XElement("PostArPayment",
new XElement("Parameters",
new XElement("PostingPeriod", postPeriod),
new XElement("IgnoreWarnings", "Y"),
new XElement("ApplyIfEntireDocumentValid", "Y"),
new XElement("ValidateOnly", "N"),
new XElement("AutoCorrectPaymentValue", "N"),
new XElement("ApplySpecificBranch", "N"),
new XElement("BranchToUse", string.Empty),
new XElement("AreaToUseForLedgerIntegration", string.Empty),
new XElement("IntegrateToCashBookInDetail", "N"),
new XElement("AutoCalculateTax", "N"),
new XElement("IgnoreAnalysis", "Y"),
new XElement("NextJournal", string.Empty),
new XElement("ValidatePasswords", "Y")
)
);

var result = paramXml.ToString();

_logger.LogDebug("Generated parameter XML with posting period {PostingPeriod}", postPeriod);

return result;
}

SYSPRO API Communication

Business Object Posting

// MepApps.Dash.Ar.Maint.PaymentReversal/Services/SysproPostService.cs (Lines 129-146)
public string PerformBusinessObjectPost(string inputXml, string paramXml)
{
string outputXml = string.Empty;
try
{
// Must execute on UI thread for SYSPRO COM interop
_dispatcher.Invoke(() =>
{
_logger.LogInformation("Calling SYSPRO business object ARSTPY");

// Call SYSPRO ENet API
outputXml = _sysproEnet.Transaction(
null, // User ID (uses current session)
"ARSTPY", // Business object
paramXml, // Parameters
inputXml // Input document
);
});

_logger.LogInformation("SYSPRO post completed. Response length: {Length}",
outputXml?.Length ?? 0);

// Log full XML for debugging (at trace level)
if (_logger.IsEnabled(LogLevel.Trace))
{
_logger.LogTrace("ARSTPY Response: XmlIn={XmlIn}, XmlParam={XmlParam}, XmlOut={XmlOut}",
inputXml, paramXml, outputXml);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in PerformBusinessObjectPost");

// Log the input/param XML to help diagnose the issue
_logger.LogError("Failed post - Input: {Input}, Params: {Params}", inputXml, paramXml);

throw new SysproPostingException("SYSPRO posting failed", ex)
{
InputXml = inputXml,
ParamXml = paramXml,
BusinessObject = "ARSTPY"
};
}
return outputXml;
}

Response Parsing

XML Output Processing

// MepApps.Dash.Ar.Maint.PaymentReversal/Services/SysproPostService.cs (Lines 148-216)
public PostArPayment_Output ParseXmlOutput(string outputXml)
{
PostArPayment_Output output = new PostArPayment_Output() { OutputXml = outputXml };
try
{
if (string.IsNullOrWhiteSpace(outputXml))
{
_logger.LogWarning("Empty output XML received from SYSPRO");
output.PostSucceeded = false;
return output;
}

// Check for error indicators
if (outputXml.Contains("RunSysproPostFromUserControl()"))
{
_logger.LogError("SYSPRO returned error indicator in output");
output.PostSucceeded = false;
return output;
}

if (!outputXml.ToLower().Contains("postarpayment"))
{
_logger.LogError("Output XML does not contain expected PostArPayment element");
output.PostSucceeded = false;
return output;
}

output.PostSucceeded = true;

XDocument doc = XDocument.Parse(outputXml);

// Parse payment items
output.Items = (from ele in doc.Descendants("Payment")
select new PostArPayment_OutputItem
{
Customer = ele.Element("Customer")?.Value,
TrnYear = ParseInt(ele.Element("Key")?.Element("TrnYear")?.Value),
TrnMonth = ParseInt(ele.Element("Key")?.Element("TrnMonth")?.Value),
Journal = ParseInt(ele.Element("Key")?.Element("Journal")?.Value)
}).ToList();

// Parse GL journals created
output.GlJournals = (from ele in doc.Descendants("StatusOfItems").Elements("GlJournal")
select new PostArPayment_OutputGlJournal
{
GlJournal = ParseInt(ele.Element("GlJournal")?.Value),
GlPeriod = ParseInt(ele.Element("GlPeriod")?.Value),
GlYear = ParseInt(ele.Element("GlYear")?.Value)
}).ToList();

// Parse summary statistics
var statusElement = doc.Descendants("StatusOfItems").FirstOrDefault();
if (statusElement != null)
{
output.ItemsProcessed = ParseInt(statusElement.Element("ItemsProcessed")?.Value);
output.ItemsInvalid = ParseInt(statusElement.Element("ItemsInvalid")?.Value);
}

var recapElement = doc.Descendants("RecapTotals").FirstOrDefault();
if (recapElement != null)
{
output.JournalsCount = ParseInt(recapElement.Element("JournalsCount")?.Value);
output.PaymentsCount = ParseInt(recapElement.Element("PaymentsCount")?.Value);
output.PaymentTotal = ParseDecimal(recapElement.Element("PaymentsTotal")?.Value);
output.DiscountsTotal = ParseDecimal(recapElement.Element("DiscountsTotal")?.Value);
output.TaxTotal = ParseDecimal(recapElement.Element("TaxTotal")?.Value);
output.AdjustmentsTotal = ParseDecimal(recapElement.Element("AdjustmentsTotal")?.Value);
output.ReceiptsTotal = ParseDecimal(recapElement.Element("ReceiptsTotal")?.Value);
}

_logger.LogInformation("Successfully parsed SYSPRO output. Processed: {Processed}, Invalid: {Invalid}",
output.ItemsProcessed, output.ItemsInvalid);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error parsing XML output");
output.PostSucceeded = false;
}

return output;
}

private int ParseInt(string value)
{
return int.TryParse(value, out int result) ? result : 0;
}

private decimal ParseDecimal(string value)
{
return decimal.TryParse(value, out decimal result) ? result : 0m;
}

Authentication and Session Management

public class SysproSessionManager
{
private readonly ISysproEnet _sysproEnet;
private readonly ILogger<SysproSessionManager> _logger;
private string _sessionId;
private DateTime _sessionExpiry;

public async Task<string> GetSessionAsync()
{
if (IsSessionValid())
{
return _sessionId;
}

return await RefreshSessionAsync();
}

private bool IsSessionValid()
{
return !string.IsNullOrEmpty(_sessionId) &&
DateTime.Now < _sessionExpiry;
}

private async Task<string> RefreshSessionAsync()
{
try
{
_logger.LogInformation("Refreshing SYSPRO session");

// Get new session from SYSPRO
_sessionId = await Task.Run(() =>
_sysproEnet.Logon(
ConfigurationManager.AppSettings["SysproUser"],
ConfigurationManager.AppSettings["SysproPassword"],
ConfigurationManager.AppSettings["SysproCompany"]
)
);

_sessionExpiry = DateTime.Now.AddMinutes(20); // Session timeout

_logger.LogInformation("SYSPRO session refreshed successfully");

return _sessionId;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to refresh SYSPRO session");
throw new SysproAuthenticationException("Unable to authenticate with SYSPRO", ex);
}
}
}

Retry and Circuit Breaker Patterns

public class ResilientSysproService
{
private readonly ICircuitBreaker _circuitBreaker;
private readonly IRetryPolicy _retryPolicy;
private readonly ILogger<ResilientSysproService> _logger;

public ResilientSysproService(ILogger<ResilientSysproService> logger)
{
_logger = logger;

_retryPolicy = Policy
.Handle<SysproTransientException>()
.WaitAndRetryAsync(
3,
retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
onRetry: (outcome, timespan, retryCount, context) =>
{
_logger.LogWarning("Retry {RetryCount} after {Delay}ms",
retryCount, timespan.TotalMilliseconds);
});

_circuitBreaker = Policy
.Handle<SysproException>()
.CircuitBreakerAsync(
5, // failures before opening
TimeSpan.FromMinutes(1), // duration open
onBreak: (result, timespan) =>
{
_logger.LogError("Circuit breaker opened for {Duration}", timespan);
},
onReset: () =>
{
_logger.LogInformation("Circuit breaker reset");
});
}

public async Task<string> PostWithResilienceAsync(string businessObject, string input, string parameters)
{
return await _circuitBreaker.ExecuteAsync(async () =>
await _retryPolicy.ExecuteAsync(async () =>
{
var result = await PostToSysproAsync(businessObject, input, parameters);

if (IsTransientError(result))
{
throw new SysproTransientException("Transient error detected");
}

return result;
})
);
}

private bool IsTransientError(string response)
{
var transientIndicators = new[]
{
"timeout",
"connection refused",
"service unavailable"
};

return transientIndicators.Any(indicator =>
response?.ToLower().Contains(indicator) ?? false);
}
}

Data Transformation

Model Mapping

public class SysproDataMapper
{
public SysproPaymentDocument MapToSysproFormat(ArReversePaymentHeader payment)
{
return new SysproPaymentDocument
{
CustomerCode = payment.Customer.PadLeft(15), // SYSPRO expects 15 chars
PaymentReference = TruncateString(payment.CheckNumber, 20),
PaymentAmount = FormatCurrency(payment.CheckValue.Value),
PaymentDate = FormatDate(payment.PaymentDate.Value),
BankCode = payment.Bank.PadLeft(10),
TransactionType = "PMT", // SYSPRO transaction type
ReversalIndicator = "R" // Reversal flag
};
}

private string FormatCurrency(decimal value)
{
// SYSPRO expects specific decimal format
return value.ToString("0000000000000.00");
}

private string FormatDate(DateTime date)
{
// SYSPRO date format YYYYMMDD
return date.ToString("yyyyMMdd");
}

private string TruncateString(string value, int maxLength)
{
if (string.IsNullOrEmpty(value))
return string.Empty.PadRight(maxLength);

return value.Length > maxLength
? value.Substring(0, maxLength)
: value.PadRight(maxLength);
}
}

Error Mapping and Handling

public class SysproErrorMapper
{
private readonly Dictionary<string, string> _errorMappings = new()
{
["Customer not on file"] = "The specified customer does not exist in SYSPRO",
["Period closed"] = "Cannot post to a closed accounting period",
["Duplicate transaction"] = "This transaction has already been posted",
["Insufficient balance"] = "Customer account has insufficient balance for reversal",
["Invalid bank"] = "The specified bank account is not valid"
};

public BusinessException MapSysproError(string sysproError)
{
foreach (var mapping in _errorMappings)
{
if (sysproError.Contains(mapping.Key))
{
return new BusinessException(mapping.Value)
{
ErrorCode = mapping.Key,
OriginalError = sysproError
};
}
}

// Unknown error
return new BusinessException($"SYSPRO error: {sysproError}")
{
ErrorCode = "SYSPRO_ERROR",
OriginalError = sysproError
};
}
}

Integration Monitoring

public class IntegrationMonitor
{
private readonly ILogger<IntegrationMonitor> _logger;
private readonly IMetrics _metrics;

public async Task<T> MonitorIntegrationCall<T>(
string operationName,
Func<Task<T>> operation)
{
var stopwatch = Stopwatch.StartNew();

try
{
_logger.LogDebug("Starting integration call: {Operation}", operationName);

var result = await operation();

stopwatch.Stop();

_metrics.RecordIntegrationSuccess(operationName, stopwatch.ElapsedMilliseconds);

_logger.LogInformation("Integration call completed: {Operation} in {Duration}ms",
operationName, stopwatch.ElapsedMilliseconds);

return result;
}
catch (Exception ex)
{
stopwatch.Stop();

_metrics.RecordIntegrationFailure(operationName, stopwatch.ElapsedMilliseconds);

_logger.LogError(ex, "Integration call failed: {Operation} after {Duration}ms",
operationName, stopwatch.ElapsedMilliseconds);

throw;
}
}
}

Best Practices

  1. Always validate XML before sending to SYSPRO
  2. Use circuit breakers to prevent cascade failures
  3. Implement retry logic for transient errors
  4. Log all integration calls with correlation IDs
  5. Transform data consistently using mappers
  6. Handle timeouts gracefully with cancellation tokens
  7. Monitor integration health with metrics

Common Pitfalls

  1. Not handling COM threading for SYSPRO interop
  2. Missing XML escaping causing parsing errors
  3. Ignoring partial success scenarios
  4. No timeout configuration for long-running posts
  5. Poor error messages from SYSPRO responses

Summary

The integration services layer provides robust, resilient communication with SYSPRO ERP through careful XML document construction, proper error handling, and resilience patterns. The implementation ensures reliable posting of payment reversals while maintaining visibility into integration health and providing meaningful error feedback.