From 05393642a0c73e030c3b29bb687831507549af17 Mon Sep 17 00:00:00 2001 From: Jason Sylvestre Date: Wed, 19 Nov 2025 12:55:06 -0800 Subject: [PATCH 1/2] Processes recharge invoices for Sloth upload Refactors the recharge invoice processing logic within the MoneyMovementJob. Improves transaction handling by introducing a database transaction for each invoice to prevent partial uploads. Adds retry logic for Sloth transaction creation to handle transient errors. Handles scenarios where debit and credit amounts do not match, marking the invoice as rejected. Adds metadata to Sloth transactions for enhanced tracking and debugging. Ensures existing Sloth transactions are correctly identified and skipped to prevent duplicates. Catches and logs errors more granularly, improving error reporting and job stability. Adds a try/catch block around the entire job so that the job does not fail halfway. --- src/Payments.Core/Jobs/MoneyMovementJob.cs | 282 ++++++++++---------- src/Payments.Mvc/Services/InvoiceService.cs | 1 - 2 files changed, 146 insertions(+), 137 deletions(-) diff --git a/src/Payments.Core/Jobs/MoneyMovementJob.cs b/src/Payments.Core/Jobs/MoneyMovementJob.cs index 13bf4a8b..c76143a3 100644 --- a/src/Payments.Core/Jobs/MoneyMovementJob.cs +++ b/src/Payments.Core/Jobs/MoneyMovementJob.cs @@ -40,7 +40,7 @@ public MoneyMovementJob(ApplicationDbContext dbContext, ISlothService slothServi public async Task FindBankReconcileTransactions(ILogger log) { - if(_financeSettings.DisableJob) + if (_financeSettings.DisableJob) { log.Information("Money Movement Job Disabled"); return; @@ -137,7 +137,7 @@ public async Task FindBankReconcileTransactions(ILogger log) debitHolding.FinancialSegmentString = _financeSettings.ClearingFinancialSegmentString; feeCredit.FinancialSegmentString = _financeSettings.FeeFinancialSegmentString; incomeCredit.FinancialSegmentString = incomeAeAccount; - + @@ -163,7 +163,7 @@ public async Task FindBankReconcileTransactions(ILogger log) SourceType = "CyberSource", }; - if(feeCredit.Amount <= 0) + if (feeCredit.Amount <= 0) { slothTransaction.Transfers = new List() { @@ -254,7 +254,7 @@ public async Task FindIncomeTransactions(ILogger log) string.Equals(t.Status, "Completed", StringComparison.OrdinalIgnoreCase) && t.Transfers.Any(r => string.Equals(r.Account, _financeSettings.FeeAccount) || string.Equals(r.FinancialSegmentString, _financeSettings.FeeFinancialSegmentString))); - if(distribution == null && invoice.CalculatedTotal <= 0.10m) + if (distribution == null && invoice.CalculatedTotal <= 0.10m) { //Ok, these are a special circumstance. If the invoice is less than 10 cents, we don't expect a fee. //The ClearingFinancialSegmentString should have a purpose code of 00 where the CyberSource txns should have a value of 45 so they should be different. @@ -298,178 +298,188 @@ public async Task ProcessRechargeTransactions(ILogger log) } log.Information("Starting ProcessRechargeTransactions Job"); - using (var ts = _dbContext.Database.BeginTransaction()) //Should this be for each invoice? If it fails we might get multiple uploads. (Can pass the link as the processor id as a catch... + try { - try + var invoices = _dbContext.Invoices + .Where(i => i.Status == Invoice.StatusCodes.Approved && i.Type == Invoice.InvoiceTypes.Recharge) + .Include(i => i.Team) + .Include(i => i.RechargeAccounts) + .ToList(); + log.Information("{count} invoices found expecting upload to sloth", invoices.Count); + + foreach (var invoice in invoices) { - var invoices = _dbContext.Invoices - .Where(i => i.Status == Invoice.StatusCodes.Approved && i.Type == Invoice.InvoiceTypes.Recharge) - .Include(i => i.Team) - .Include(i => i.RechargeAccounts) - .ToList(); - log.Information("{count} invoices found expecting upload to sloth", invoices.Count); - - foreach (var invoice in invoices) + using (var ts = _dbContext.Database.BeginTransaction()) //Should this be for each invoice? If it fails we might get multiple uploads. (Can pass the link as the processor id as a catch... { - - var slothChecks = await _slothService.GetTransactionsByProcessorId(invoice.GetFormattedId(), true); - var slothCheck = slothChecks?.Where(a => a.Status != "Cancelled").FirstOrDefault(); //PendingApproval, Scheduled, Processing, Rejected, Completed - if (slothCheck != null) + try { - log.Warning("Invoice {id} already has a sloth transaction with processor id {processorId}. Skipping creation.", invoice.Id, invoice.GetFormattedId()); - //It probably has an incorrect status. Lets fix it. - invoice.Status = Invoice.StatusCodes.Processing; - invoice.KfsTrackingNumber = slothCheck.KfsTrackingNumber; - await _dbContext.SaveChangesAsync(); - continue; - } + var slothChecks = await _slothService.GetTransactionsByProcessorId(invoice.GetFormattedId(), true); + var slothCheck = slothChecks?.Where(a => a.Status != "Cancelled").FirstOrDefault(); //PendingApproval, Scheduled, Processing, Rejected, Completed + if (slothCheck != null) + { + log.Warning("Invoice {id} already has a sloth transaction with processor id {processorId}. Skipping creation.", invoice.Id, invoice.GetFormattedId()); + //It probably has an incorrect status. Lets fix it. + invoice.Status = Invoice.StatusCodes.Processing; + invoice.KfsTrackingNumber = slothCheck.KfsTrackingNumber; + await _dbContext.SaveChangesAsync(); + ts.Commit(); + continue; + } - var creditTransfers = new List(); - var debitTransfers = new List(); - foreach (var recharge in invoice.RechargeAccounts.Where(a => a.Direction == RechargeAccount.CreditDebit.Credit)) - { - var creditTransfer = new CreateTransfer() + var creditTransfers = new List(); + var debitTransfers = new List(); + + foreach (var recharge in invoice.RechargeAccounts.Where(a => a.Direction == RechargeAccount.CreditDebit.Credit)) { - Amount = recharge.Amount, - Direction = Transfer.CreditDebit.Credit, - Description = $"Recharge Credit INV {invoice.GetFormattedId()}", - }; + var creditTransfer = new CreateTransfer() + { + Amount = recharge.Amount, + Direction = Transfer.CreditDebit.Credit, + Description = $"Recharge Credit INV {invoice.GetFormattedId()}", + }; - creditTransfer.FinancialSegmentString = recharge.FinancialSegmentString; + creditTransfer.FinancialSegmentString = recharge.FinancialSegmentString; - creditTransfers.Add(creditTransfer); - } + creditTransfers.Add(creditTransfer); + } - foreach (var recharge in invoice.RechargeAccounts.Where(a => a.Direction == RechargeAccount.CreditDebit.Debit)) - { - var debitTransfer = new CreateTransfer() + foreach (var recharge in invoice.RechargeAccounts.Where(a => a.Direction == RechargeAccount.CreditDebit.Debit)) { - Amount = recharge.Amount, - Direction = Transfer.CreditDebit.Debit, - Description = $"Recharge Debit INV {invoice.GetFormattedId()}", - }; - debitTransfer.FinancialSegmentString = recharge.FinancialSegmentString; - debitTransfers.Add(debitTransfer); - } - - if (debitTransfers.Sum(t => t.Amount) != creditTransfers.Sum(t => t.Amount)) - { - log.Error("Invoice {id} debit and credit amounts do not match. Debits: {debits} Credits: {credits}", invoice.Id, debitTransfers.Sum(t => t.Amount), creditTransfers.Sum(t => t.Amount)); - invoice.Status = Invoice.StatusCodes.Rejected; + var debitTransfer = new CreateTransfer() + { + Amount = recharge.Amount, + Direction = Transfer.CreditDebit.Debit, + Description = $"Recharge Debit INV {invoice.GetFormattedId()}", + }; + debitTransfer.FinancialSegmentString = recharge.FinancialSegmentString; + debitTransfers.Add(debitTransfer); + } - var actionEntry = new History() + if (debitTransfers.Sum(t => t.Amount) != creditTransfers.Sum(t => t.Amount)) { - Type = HistoryActionTypes.RechargeRejected.TypeCode, - ActionDateTime = DateTime.UtcNow, - Data = "Invoice debit and credit amounts do not match." - }; - invoice.History.Add(actionEntry); + log.Error("Invoice {id} debit and credit amounts do not match. Debits: {debits} Credits: {credits}", invoice.Id, debitTransfers.Sum(t => t.Amount), creditTransfers.Sum(t => t.Amount)); + invoice.Status = Invoice.StatusCodes.Rejected; - await _dbContext.SaveChangesAsync(); - continue; - } + var actionEntry = new History() + { + Type = HistoryActionTypes.RechargeRejected.TypeCode, + ActionDateTime = DateTime.UtcNow, + Data = "Invoice debit and credit amounts do not match." + }; + invoice.History.Add(actionEntry); - // setup transaction - var merchantUrl = $"{_paymentsApiSettings.BaseUrl}/{invoice.Team.Slug}/invoices/details/{invoice.Id}"; - var payPageUrl = $"{_paymentsApiSettings.BaseUrl}/recharge/pay/{invoice.LinkId}"; - var slothTransaction = new CreateTransaction() - { - AutoApprove = _financeSettings.RechargeAutoApprove, - ValidateFinancialSegmentStrings = _financeSettings.ValidateRechargeFinancialSegmentString, - MerchantTrackingNumber = invoice.Id.ToString(), //use the id here so these get tied together in sloth - MerchantTrackingUrl = merchantUrl, - TransactionDate = DateTime.UtcNow, - Description = $"Recharge INV {invoice.GetFormattedId()}", - Source = _financeSettings.RechargeSlothSourceName, - SourceType = "Recharge", - KfsTrackingNumber = !string.IsNullOrWhiteSpace(invoice.KfsTrackingNumber) ? invoice.KfsTrackingNumber : null, // Will be set when processed in sloth, we will get it from the response - Transfers = debitTransfers.Concat(creditTransfers).ToList(), - ProcessorTrackingNumber = invoice.GetFormattedId(), - }; - slothTransaction.AddMetadata("Team Name", invoice.Team.Name); - slothTransaction.AddMetadata("Team Slug", invoice.Team.Slug); - slothTransaction.AddMetadata("Invoice", invoice.GetFormattedId()); - slothTransaction.AddMetadata("Payment Link", payPageUrl); - foreach (var recharge in invoice.RechargeAccounts.Where(a => a.Direction == RechargeAccount.CreditDebit.Debit)) - { - slothTransaction.AddMetadata(recharge.FinancialSegmentString, $"Entered By: {recharge.EnteredByName} ({recharge.EnteredByKerb})"); - slothTransaction.AddMetadata(recharge.FinancialSegmentString, $"Approved By: {recharge.ApprovedByName} ({recharge.ApprovedByKerb})"); - if(!string.IsNullOrWhiteSpace( recharge.Notes)) + await _dbContext.SaveChangesAsync(); + ts.Commit(); + continue; + } + + // setup transaction + var merchantUrl = $"{_paymentsApiSettings.BaseUrl}/{invoice.Team.Slug}/invoices/details/{invoice.Id}"; + var payPageUrl = $"{_paymentsApiSettings.BaseUrl}/recharge/pay/{invoice.LinkId}"; + var slothTransaction = new CreateTransaction() + { + AutoApprove = _financeSettings.RechargeAutoApprove, + ValidateFinancialSegmentStrings = _financeSettings.ValidateRechargeFinancialSegmentString, + MerchantTrackingNumber = invoice.Id.ToString(), //use the id here so these get tied together in sloth + MerchantTrackingUrl = merchantUrl, + TransactionDate = DateTime.UtcNow, + Description = $"Recharge INV {invoice.GetFormattedId()}", + Source = _financeSettings.RechargeSlothSourceName, + SourceType = "Recharge", + KfsTrackingNumber = !string.IsNullOrWhiteSpace(invoice.KfsTrackingNumber) ? invoice.KfsTrackingNumber : null, // Will be set when processed in sloth, we will get it from the response + Transfers = debitTransfers.Concat(creditTransfers).ToList(), + ProcessorTrackingNumber = invoice.GetFormattedId(), + }; + slothTransaction.AddMetadata("Team Name", invoice.Team.Name); + slothTransaction.AddMetadata("Team Slug", invoice.Team.Slug); + slothTransaction.AddMetadata("Invoice", invoice.GetFormattedId()); + slothTransaction.AddMetadata("Payment Link", payPageUrl); + foreach (var recharge in invoice.RechargeAccounts.Where(a => a.Direction == RechargeAccount.CreditDebit.Debit)) { - slothTransaction.AddMetadata(recharge.FinancialSegmentString, $"Notes: {recharge.Notes}"); + slothTransaction.AddMetadata(recharge.FinancialSegmentString, $"Entered By: {recharge.EnteredByName} ({recharge.EnteredByKerb})"); + slothTransaction.AddMetadata(recharge.FinancialSegmentString, $"Approved By: {recharge.ApprovedByName} ({recharge.ApprovedByKerb})"); + if (!string.IsNullOrWhiteSpace(recharge.Notes)) + { + slothTransaction.AddMetadata(recharge.FinancialSegmentString, $"Notes: {recharge.Notes}"); + } } - } - try - { - // create transaction (But before we do this, lets try to get it by processor id to avoid duplicates) - var response = await _slothService.CreateTransaction(slothTransaction, true); + try + { + // create transaction (But before we do this, lets try to get it by processor id to avoid duplicates) + var response = await _slothService.CreateTransaction(slothTransaction, true); - if (response == null || response.Id == null) + if (response == null || response.Id == null) + { + log.Error("Invoice {id} sloth transaction creation failed.", invoice.Id); + continue; + } + invoice.Status = Invoice.StatusCodes.Processing; + invoice.KfsTrackingNumber = response.KfsTrackingNumber; + await _dbContext.SaveChangesAsync(); + ts.Commit(); + } + catch (HttpServiceInternalException ex) when (ex.StatusCode == System.Net.HttpStatusCode.BadRequest) { - log.Error("Invoice {id} sloth transaction creation failed.", invoice.Id); + log.Error(ex, "Bad request creating sloth transaction for invoice {id}. Message: {message}, Content: {content}", + invoice.Id, ex.Message, ex.Content); + invoice.Status = Invoice.StatusCodes.Rejected; + var actionEntry = new History() + { + Type = HistoryActionTypes.RechargeRejected.TypeCode, + ActionDateTime = DateTime.UtcNow, + Data = $"Recharge transaction rejected by Sloth: {ex.Content}" + }; + invoice.History.Add(actionEntry); + await _dbContext.SaveChangesAsync(); + ts.Commit(); continue; } - invoice.Status = Invoice.StatusCodes.Processing; - invoice.KfsTrackingNumber = response.KfsTrackingNumber; - await _dbContext.SaveChangesAsync(); - } - catch (HttpServiceInternalException ex) when (ex.StatusCode == System.Net.HttpStatusCode.BadRequest) - { - log.Error(ex, "Bad request creating sloth transaction for invoice {id}. Message: {message}, Content: {content}", - invoice.Id, ex.Message, ex.Content); - invoice.Status = Invoice.StatusCodes.Rejected; - var actionEntry = new History() + catch (HttpServiceInternalException ex) { - Type = HistoryActionTypes.RechargeRejected.TypeCode, - ActionDateTime = DateTime.UtcNow, - Data = $"Recharge transaction rejected by Sloth: {ex.Content}" - }; - invoice.History.Add(actionEntry); - continue; - } - catch (HttpServiceInternalException ex) - { - log.Error(ex, "Error creating sloth transaction for invoice {id}. Status: {statusCode}, Content: {content}", - invoice.Id, ex.StatusCode, ex.Content); - //Leave it in approved to try again later - continue; + log.Error(ex, "Error creating sloth transaction for invoice {id}. Status: {statusCode}, Content: {content}", + invoice.Id, ex.StatusCode, ex.Content); + //Leave it in approved to try again later + continue; + } + catch (Exception ex) + { + log.Error(ex, "Error creating sloth transaction for invoice {id}", invoice.Id); + continue; + } + await _dbContext.SaveChangesAsync(); + ts.Commit(); } catch (Exception ex) { - log.Error(ex, "Error creating sloth transaction for invoice {id}", invoice.Id); + log.Error(ex, "Error processing invoice {id}", invoice.Id); + ts.Rollback(); continue; } - - } + } //End of foreach invoice - log.Information("Finishing ProcessRechargeTransactions Job"); - await _dbContext.SaveChangesAsync(); - ts.Commit(); - } - catch (Exception ex) - { - //TODO: Review this - log.Error(ex, ex.Message); - ts.Rollback(); - throw; - } + + log.Information("Finishing ProcessRechargeTransactions Job"); + } + catch (Exception ex) + { + log.Error(ex, ex.Message); } } public async Task ProcessRechargePendingTransactions(ILogger log) { - if(_financeSettings.RechargeDisableJob) + if (_financeSettings.RechargeDisableJob) { log.Information("Recharge Money Movement Job Disabled"); return; } log.Information("Starting ProcessRechargePendingTransactions Job"); - using (var ts = _dbContext.Database.BeginTransaction()) + using (var ts = _dbContext.Database.BeginTransaction()) { try { @@ -493,7 +503,7 @@ public async Task ProcessRechargePendingTransactions(ILogger log) //var slothTransaction = await _slothService.GetTransactionsByProcessorId(invoice.GetFormattedId(), true); //Could also use this way. They should both be the same info // look for transfers into the fees account that have completed var transaction = transactions?.FirstOrDefault(t => - string.Equals(t.Status, "Completed", StringComparison.OrdinalIgnoreCase)); + string.Equals(t.Status, "Completed", StringComparison.OrdinalIgnoreCase)); if (transaction != null) { @@ -529,7 +539,7 @@ public async Task ProcessRechargePendingTransactions(ILogger log) if (transaction != null) { log.Information("Invoice {id} recharge distribution cancelled with transaction: {transactionId}", invoice.Id, transaction.Id); - + invoice.Status = Invoice.StatusCodes.Rejected; var actionEntry = new History() { diff --git a/src/Payments.Mvc/Services/InvoiceService.cs b/src/Payments.Mvc/Services/InvoiceService.cs index 5ddfcbb4..812c20ac 100644 --- a/src/Payments.Mvc/Services/InvoiceService.cs +++ b/src/Payments.Mvc/Services/InvoiceService.cs @@ -46,7 +46,6 @@ public async Task> CreateInvoices(CreateInvoiceModel mode if (model.Type == Invoice.InvoiceTypes.Recharge) { - //TODO: Server side validation. //Must have at least one credit recharge account. //All recharge accounts must be valid. //All credit recharge account must be 100% of total. From ee461fac078359fc6a4e1e6ab09aeeb90b14108d Mon Sep 17 00:00:00 2001 From: Jason Sylvestre Date: Wed, 19 Nov 2025 13:07:57 -0800 Subject: [PATCH 2/2] Improves chart string validation display Refactors the chart string validation status display to use DOM manipulation for safer and more robust HTML element creation and modification. This change avoids potential XSS vulnerabilities and improves the clarity of validation messages and warnings by directly appending elements to the status span. --- .../Views/Invoices/Details.cshtml | 43 +++++++++++-------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/src/Payments.Mvc/Views/Invoices/Details.cshtml b/src/Payments.Mvc/Views/Invoices/Details.cshtml index 79e2c492..097302c2 100644 --- a/src/Payments.Mvc/Views/Invoices/Details.cshtml +++ b/src/Payments.Mvc/Views/Invoices/Details.cshtml @@ -1176,31 +1176,33 @@ else } }); - let resultHtml = ''; + // Clear existing content + statusSpan.textContent = ''; if (validationResult.isValid) { - resultHtml = ''; + const checkIcon = document.createElement('i'); + checkIcon.className = 'fas fa-check-circle text-success'; + checkIcon.setAttribute('title', 'Valid chart string'); + statusSpan.appendChild(checkIcon); // Add warnings if they exist if (validationResult.warnings && validationResult.warnings.length > 0) { - const sanitizedWarnings = validationResult.warnings.map(w => { - const key = document.createTextNode(w.key).textContent; - const value = document.createTextNode(w.value).textContent; - return key + ': ' + value; - }).join('; '); - const warningTitle = document.createElement('div'); - warningTitle.textContent = sanitizedWarnings; - resultHtml += ' '; + const warningMessages = validationResult.warnings.map(w => + `${w.key}: ${w.value}` + ).join('; '); + + const warningIcon = document.createElement('i'); + warningIcon.className = 'fas fa-exclamation-triangle text-warning ms-1'; + warningIcon.setAttribute('title', warningMessages); + statusSpan.appendChild(warningIcon); } } else { - const errorDiv = document.createElement('div'); - errorDiv.textContent = validationResult.messages ? validationResult.messages.join('; ') : 'Invalid chart string'; - const errorMessages = errorDiv.textContent; - resultHtml = ''; + const errorMessages = validationResult.messages ? validationResult.messages.join('; ') : 'Invalid chart string'; + const errorIcon = document.createElement('i'); + errorIcon.className = 'fas fa-times-circle text-danger'; + errorIcon.setAttribute('title', errorMessages); + statusSpan.appendChild(errorIcon); } - - statusSpan.innerHTML = resultHtml; // Initialize tooltips for the newly added elements $(statusSpan).find('[title]').each(function() { @@ -1220,7 +1222,12 @@ else } }); - statusSpan.innerHTML = ''; + // Clear existing content and create warning icon safely + statusSpan.textContent = ''; + const warningIcon = document.createElement('i'); + warningIcon.className = 'fas fa-exclamation-triangle text-warning'; + warningIcon.setAttribute('title', errorMessage); + statusSpan.appendChild(warningIcon); // Initialize tooltips for the newly added elements $(statusSpan).find('[title]').each(function() {