+
diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/Notifications/Index.css b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/Notifications/Index.css
index b0b86a41e4..875f2fe638 100644
--- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/Notifications/Index.css
+++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/Notifications/Index.css
@@ -13,6 +13,19 @@
.search-action-bar_search-wrapper {
flex: 1;
min-width: 220px;
+ display: flex;
+ flex-direction: row;
+ align-items: flex-end;
+ gap: 1rem;
+}
+
+.notification-filter-row {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: nowrap;
+ align-items: flex-end;
+ margin-bottom: 0 !important;
+ gap: 1rem;
}
.date-input-filter-div {
@@ -24,9 +37,10 @@
padding-bottom: 0px !important;
}
-.date-input-filter-div label.form-label {
- font-size: 13.6px;
- margin-bottom: 0.3rem;
+.date-input-filter-div label.form-label,
+.notification-view-filter label.form-label {
+ font-size: 13.6px !important;
+ margin-bottom: 0.3rem !important;
}
.custom-date-range-container-div {
@@ -43,6 +57,28 @@
text-overflow: ellipsis;
}
+#notificationView,
+#quickDateRange {
+ border: 2px solid #2e5dd7 !important;
+ border-radius: 8px !important;
+}
+
+.notification-view-filter {
+ flex: 0 0 auto;
+}
+
+.notification-view-filter select {
+ width: max-content;
+ min-width: 5rem;
+}
+
+#search {
+ flex: 1 1 auto;
+ height: 39.2px !important;
+ margin-top: 0 !important;
+ margin-bottom: 4.8px !important;
+}
+
#dynamicButtonContainerId {
flex-shrink: 0;
display: flex;
@@ -99,6 +135,11 @@
}
@media (max-width: 768px) {
+ .search-action-bar_search-wrapper,
+ .notification-filter-row {
+ flex-wrap: wrap;
+ }
+
#notificationEmailModal .modal-dialog {
width: calc(100vw - 1rem);
margin: 0.5rem auto;
diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/Notifications/Index.js b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/Notifications/Index.js
index 8b5ca6bc51..e317d7921c 100644
--- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/Notifications/Index.js
+++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Pages/Notifications/Index.js
@@ -8,9 +8,14 @@ $(function () {
const STORAGE_KEYS = {
quickRange: 'Notifications_QuickRange',
fromDate: 'Notifications_FromDate',
- toDate: 'Notifications_ToDate'
+ toDate: 'Notifications_ToDate',
+ view: 'Notifications_View'
};
let notificationTableFilters = { dateFrom: null, dateTo: null };
+ const savedNotificationView = localStorage.getItem(STORAGE_KEYS.view);
+ let notificationViewMode = ['both', 'application', 'applicant'].includes(savedNotificationView)
+ ? savedNotificationView
+ : 'both';
// The Date From/To inputs are hidden by default and revealed by the Filter toggle
// (or when 'Custom Ranges' is selected). The Quick Range dropdown is always visible.
let dateInputsForcedByFilter = false;
@@ -18,9 +23,11 @@ $(function () {
const UIElements = {
quickDateRange: $('#quickDateRange'),
inputFilter: $('.date-input-filter'),
+ notificationView: $('#notificationView'),
submittedFromInput: $('#submittedFromDate'),
submittedToInput: $('#submittedToDate')
};
+ UIElements.notificationView.val(notificationViewMode);
const listColumns = getColumns();
const defaultVisibleColumns = [
@@ -70,7 +77,14 @@ $(function () {
const selectedEmailElement = document.getElementById('notificationSelectedEmail');
if (selectedEmailElement?.textContent) {
window.EmailsWidget?.reinitialize?.();
- PubSub.publish('email_selected', JSON.parse(selectedEmailElement.textContent));
+ const selectedEmail = JSON.parse(selectedEmailElement.textContent);
+ const selectedApplicationId = selectedRows[0].applicationId;
+ const isApplicantEmail = !selectedApplicationId
+ || selectedApplicationId === '00000000-0000-0000-0000-000000000000';
+ const selectedEmailTopic = isApplicantEmail
+ ? 'applicant_email_selected'
+ : 'email_selected';
+ PubSub.publish(selectedEmailTopic, selectedEmail);
}
bootstrap.Modal.getOrCreateInstance(document.getElementById('notificationEmailModal')).show();
})
@@ -107,7 +121,15 @@ $(function () {
dateTo: notificationTableFilters.dateTo
};
},
- responseCallback,
+ responseCallback: function (result) {
+ const items = result.items.filter(item => {
+ const isApplicantNotification = !item.applicationId || item.applicationId === '00000000-0000-0000-0000-000000000000';
+ return notificationViewMode === 'both'
+ || (notificationViewMode === 'applicant' && isApplicantNotification)
+ || (notificationViewMode === 'application' && !isApplicantNotification);
+ });
+ return responseCallback({ ...result, items });
+ },
actionButtons,
pagingEnabled: true,
reorderEnabled: true,
@@ -118,6 +140,8 @@ $(function () {
fixedHeaders: true
});
+ updateSubmissionColumnVisibility();
+
function updateOpenButtonState() {
const selectedRows = dataTable.rows({ selected: true }).data().toArray();
const canOpen = selectedRows.length === 1 && !!selectedRows[0].id;
@@ -164,6 +188,13 @@ $(function () {
UIElements.quickDateRange.on('change', handleQuickDateRangeChange);
UIElements.inputFilter.on('change', handleInputFilterChange);
+ UIElements.notificationView.on('change', function () {
+ notificationViewMode = $(this).val() || 'both';
+ localStorage.setItem(STORAGE_KEYS.view, notificationViewMode);
+ updateSubmissionColumnVisibility();
+ dataTable.rows().deselect();
+ dataTable.ajax.reload(null, true);
+ });
// Reveal the Date From/To inputs together with the per-column filter row. filterRow.js emits
// "filterRow-visibility" when the user flips the "Show Filter Row" toggle in the Filter popover.
dataTable.on('filterRow-visibility', function (e, visible) {
@@ -216,7 +247,7 @@ $(function () {
setDateRangeFilters(selectedRange, range);
setDateRangeLocalStorage(selectedRange, range);
updateDateInputsVisibility();
- notificationsTable.ajax.reload(null, true);
+ dataTable.ajax.reload(null, true);
}
function handleInputFilterChange() {
@@ -230,8 +261,7 @@ $(function () {
localStorage.setItem(STORAGE_KEYS.quickRange, 'custom');
localStorage.setItem(STORAGE_KEYS.fromDate, notificationTableFilters.dateFrom || '');
localStorage.setItem(STORAGE_KEYS.toDate, notificationTableFilters.dateTo || '');
-
- notificationsTable.ajax.reload(null, true);
+ dataTable.ajax.reload(null, true);
}
function setDateRangeFilters(quickDateRange, range) {
@@ -279,13 +309,17 @@ $(function () {
// ============================ Columns ============================
+ function updateSubmissionColumnVisibility() {
+ dataTable.column('submissionReferenceNo:name').visible(notificationViewMode !== 'applicant');
+ }
+
function getColumns() {
let columnIndex = 1;
const columns = [
getSelectColumn('Select Notification', 'id', 'notifications'),
getNotificationIdColumn(columnIndex++),
getSubmissionIdColumn(columnIndex++),
- getTextColumn(columnIndex++, l('NotificationList:ApplicantName'), 'applicantName'),
+ getApplicantNameColumn(columnIndex++),
getSentDateColumn(columnIndex++),
getTextColumn(columnIndex++, l('NotificationList:Status'), 'status', { width: '7rem', className: 'data-table-header text-nowrap' }),
getTextColumn(columnIndex++, l('NotificationList:From'), 'fromAddress'),
@@ -330,6 +364,23 @@ $(function () {
};
}
+ function getApplicantNameColumn(columnIndex) {
+ return {
+ title: l('NotificationList:ApplicantName'),
+ name: 'applicantName',
+ data: 'applicantName',
+ className: 'data-table-header text-nowrap',
+ index: columnIndex,
+ render: function (data, type, row) {
+ const safe = $.fn.dataTable.render.text().display(data || '');
+ if (type !== 'display' || !data || !row.applicantId || !guidPattern.test(row.applicantId)) {
+ return safe || null;
+ }
+ return `
${safe}`;
+ }
+ };
+ }
+
function getSentDateColumn(columnIndex) {
return {
title: l('NotificationList:SentDate'),
diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.js b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.js
index 630dd8bb11..6990c18554 100644
--- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.js
+++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.js
@@ -844,10 +844,8 @@ $(function () {
}
function handleDeleteCanCheckError(templateId) {
- // If check fails, proceed with deletion
- proceedWithDelete(templateId, function () {
- PubSub.publish('reload_templates_table_with_close');
- });
+ // If check fails, do not proceed with deletion. Let the user know the preflight check failed.
+ abp.notify.error('Unable to verify whether this template can be safely deleted. Deletion has been cancelled.');
}
function checkCanDeleteTemplate(templateId) {
diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Application.Tests/ScheduledNotificationHelperTests.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Application.Tests/ScheduledNotificationHelperTests.cs
index 8a98d4bb5b..35c35d916d 100644
--- a/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Application.Tests/ScheduledNotificationHelperTests.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Application.Tests/ScheduledNotificationHelperTests.cs
@@ -63,6 +63,35 @@ public void BuildTokenValues_ForApplicationTemplate_PreservesApplicationValues()
values["unity_application_id"].ShouldBe("UNITY-456");
}
+ [Fact]
+ public void BuildTokenValues_ForDeclinedApplication_MapsDisplayDeclineRationale()
+ {
+ var application = new Application
+ {
+ Applicant = new Applicant { ApplicantName = "VelangTest2", OrgName = "DEC DESIGN" },
+ ApplicationStatus = new ApplicationStatus { InternalStatus = "Declined" },
+ ApplicationForm = new ApplicationForm { Category = "Community" },
+ ReferenceNo = "SUB-123",
+ DeclineRational = "INELIGIBLE_PROJECT",
+ ProjectName = "Test project",
+ RequestedAmount = 1000m,
+ UnityApplicationId = "UNITY-123"
+ };
+ var applicantAgent = new ApplicantAgent { Name = "Test Contact", Title = "Director" };
+
+ var values = ScheduledNotificationHelper.BuildTokenValues(application, applicantAgent);
+
+ values["applicant_name"].ShouldBe("VelangTest2");
+ values["organization_name"].ShouldBe("DEC DESIGN");
+ values["decline_rationale"].ShouldBe("Ineligible Project");
+ values["status"].ShouldBe("Declined");
+ values["contact_full_name"].ShouldBe("Test Contact");
+ values["contact_title"].ShouldBe("Director");
+ values["category"].ShouldBe("Community");
+ values["submission_number"].ShouldBe("SUB-123");
+ values["unity_application_id"].ShouldBe("UNITY-123");
+ }
+
[Fact]
public void BuildTokenValues_ForApplicantTemplate_RendersMissingOptionalValuesAsEmpty()
{
diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Application.Tests/TemplatesServiceTests.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Application.Tests/TemplatesServiceTests.cs
index 60664aafcd..7e62b2d5a7 100644
--- a/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Application.Tests/TemplatesServiceTests.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.Application.Tests/TemplatesServiceTests.cs
@@ -1,4 +1,4 @@
-using System.Collections.Generic;
+using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using NSubstitute;
@@ -52,6 +52,8 @@ private static TemplateService CreateService(IEnumerable
varia
return new TemplateService(
Substitute.For(),
Substitute.For(),
- variablesRepository);
+ variablesRepository,
+ Substitute.For(),
+Substitute.For>());
}
}
\ No newline at end of file
diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.EntityFrameworkCore.Tests/TemplateLockRepositoryTests.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.EntityFrameworkCore.Tests/TemplateLockRepositoryTests.cs
new file mode 100644
index 0000000000..79acc8fded
--- /dev/null
+++ b/applications/Unity.GrantManager/modules/Unity.Notifications/test/Unity.Notifications.EntityFrameworkCore.Tests/TemplateLockRepositoryTests.cs
@@ -0,0 +1,47 @@
+using System;
+using System.Threading.Tasks;
+using Shouldly;
+using Unity.Notifications.EntityFrameworkCore;
+using Unity.Notifications.Templates;
+using Volo.Abp.MultiTenancy;
+using Xunit;
+
+namespace Unity.Notifications;
+
+public class TemplateLockRepositoryTests : NotificationsEntityFrameworkCoreTestBase
+{
+ [Fact]
+ public async Task LockActiveAsync_ReturnsOnlyActiveTemplatesForCurrentTenant()
+ {
+ var repository = GetRequiredService();
+ var tenant = GetRequiredService();
+ var tenantId = Guid.NewGuid();
+ var template = new EmailTemplate(Guid.NewGuid(), "test", "", "", "", "", "") { TenantId = tenantId };
+
+ using (tenant.Change(tenantId))
+ {
+ await WithUnitOfWorkAsync(() => repository.InsertAsync(template, autoSave: true));
+ await WithUnitOfWorkAsync(async () =>
+ {
+ (await repository.LockActiveAsync(template.Id)).ShouldNotBeNull();
+ });
+ }
+
+ using (tenant.Change(Guid.NewGuid()))
+ {
+ await WithUnitOfWorkAsync(async () =>
+ {
+ (await repository.LockActiveAsync(template.Id)).ShouldBeNull();
+ });
+ }
+
+ using (tenant.Change(tenantId))
+ {
+ await WithUnitOfWorkAsync(() => repository.DeleteAsync(template.Id));
+ await WithUnitOfWorkAsync(async () =>
+ {
+ (await repository.LockActiveAsync(template.Id)).ShouldBeNull();
+ });
+ }
+ }
+}
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/PaymentRequest.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/PaymentRequest.cs
index 4742e2178b..1d1be952b3 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/PaymentRequest.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/PaymentRequest.cs
@@ -163,6 +163,19 @@ public PaymentRequest SetPaymentRequestStatus(PaymentRequestStatus status)
return this;
}
+ public void SetLevel3ApprovalRequired(bool required, Guid approvalId)
+ {
+ var level3Approval = ExpenseApprovals.FirstOrDefault(x => x.Type == ExpenseApprovalType.Level3);
+ if (required && level3Approval == null)
+ {
+ ExpenseApprovals.Add(new ExpenseApproval(approvalId, ExpenseApprovalType.Level3));
+ }
+ else if (!required && level3Approval != null)
+ {
+ ExpenseApprovals.Remove(level3Approval);
+ }
+ }
+
public PaymentRequest SetInvoiceStatus(string status)
{
InvoiceStatus = status;
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentsManager.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentsManager.cs
index 0960c1a8fb..e393e06100 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentsManager.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentsManager.cs
@@ -127,6 +127,7 @@ public async Task TriggerAction(Guid paymentRequestsId, PaymentA
{
var index = paymentRequest.ExpenseApprovals.FindIndex(i => i.Type == ExpenseApprovalType.Level2);
paymentRequest.ExpenseApprovals[index].Approve(currentUserId);
+ paymentRequest.SetLevel3ApprovalRequired(true, GuidGenerator.Create());
statusChangedTo = PaymentRequestStatus.L3Pending;
}
@@ -150,6 +151,7 @@ public async Task TriggerAction(Guid paymentRequestsId, PaymentA
{
var index = paymentRequest.ExpenseApprovals.FindIndex(i => i.Type == ExpenseApprovalType.Level2);
paymentRequest.ExpenseApprovals[index].Approve(currentUserId);
+ paymentRequest.SetLevel3ApprovalRequired(false, GuidGenerator.Create());
}
else if (await HasPermissionAsync(PaymentsPermissions.Payments.L3ApproveOrDecline) && paymentRequest.Status == PaymentRequestStatus.L3Pending)
{
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/FinancialNotificationSummaryWorker.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/FinancialNotificationSummaryWorker.cs
index 84e5e516d0..4b24c4585e 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/FinancialNotificationSummaryWorker.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/FinancialNotificationSummaryWorker.cs
@@ -2,7 +2,6 @@
using System;
using System.Threading.Tasks;
using Unity.Payments.PaymentRequests.Notifications;
-using Volo.Abp.BackgroundWorkers.Quartz;
using Volo.Abp.SettingManagement;
using Microsoft.Extensions.Logging;
using Unity.Payments.PaymentRequests.BackgroundJobWorkers;
@@ -13,7 +12,7 @@
namespace Unity.Payments.PaymentRequests;
[DisallowConcurrentExecution]
-public class FinancialNotificationSummaryWorker : QuartzBackgroundWorkerBase
+public class FinancialNotificationSummaryWorker : UnityQuartzBackgroundWorkerBase
{
private readonly FinancialSummaryNotifier _financialSummaryNotifier;
private readonly IEnumerable _strategies;
@@ -45,10 +44,15 @@ public FinancialNotificationSummaryWorker(
.Build();
}
- public override async Task Execute(IJobExecutionContext context)
+ protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext context)
{
Logger.LogInformation("FinancialNotificationSummary Execute");
- await _financialSummaryNotifier.NotifyFailedPayments(_strategies);
+ foreach (var tenantId in await _financialSummaryNotifier.GetTenantIdsAsync())
+ {
+ await ExecuteInTenantAuditScopeAsync(
+ tenantId,
+ () => _financialSummaryNotifier.NotifyFailedPaymentsForTenantAsync(tenantId, _strategies));
+ }
}
private static string ResolveCronExpression(ISettingManager settingManager, ILogger logger)
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/FinancialSummaryNotifier.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/FinancialSummaryNotifier.cs
index 468ae9172c..3e88926f2d 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/FinancialSummaryNotifier.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/FinancialSummaryNotifier.cs
@@ -25,13 +25,24 @@ public class FinancialSummaryNotifier(
ILocalEventBus localEventBus,
ILogger logger) : ISingletonDependency
{
- public async Task NotifyFailedPayments(IEnumerable strategies)
+ public async Task> GetTenantIdsAsync()
{
var tenants = await tenantRepository.GetListAsync();
- foreach (var tenantId in tenants.Select(tenant => tenant.Id))
+ return tenants.Select(tenant => tenant.Id).ToList();
+ }
+
+ public async Task NotifyFailedPayments(IEnumerable strategies)
+ {
+ foreach (var tenantId in await GetTenantIdsAsync())
+ {
+ await NotifyFailedPaymentsForTenantAsync(tenantId, strategies);
+ }
+ }
+
+ public async Task NotifyFailedPaymentsForTenantAsync(Guid tenantId, IEnumerable strategies)
+ {
+ using (currentTenant.Change(tenantId))
{
- using (currentTenant.Change(tenantId))
- {
List failedPaymentList = await GetFailedPayments();
if (failedPaymentList != null && failedPaymentList.Count > 0)
{
@@ -61,7 +72,7 @@ public async Task NotifyFailedPayments(IEnumerable stra
if (recipientEmails.Count == 0)
{
logger.LogWarning("NotifyFailedPayments: No recipients found from any strategy for tenant {TenantId}", tenantId);
- continue;
+ return;
}
var defaultFromAddress = await settingProvider.GetOrNullAsync(NotificationsSettings.Mailing.DefaultFromAddress);
@@ -83,7 +94,6 @@ await localEventBus.PublishAsync(
}
}
- }
}
}
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/ReconciliationProducer.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/ReconciliationProducer.cs
index 73c187eb55..923839903d 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/ReconciliationProducer.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/BackgroundJobWorkers/ReconciliationProducer.cs
@@ -1,6 +1,5 @@
using System.Threading.Tasks;
using Quartz;
-using Volo.Abp.BackgroundWorkers.Quartz;
using Volo.Abp.SettingManagement;
using Unity.Modules.Shared.Utils;
using Unity.Payments.Settings;
@@ -9,7 +8,7 @@
namespace Unity.Payments.PaymentRequests;
[DisallowConcurrentExecution]
-public class ReconciliationProducer : QuartzBackgroundWorkerBase
+public class ReconciliationProducer : UnityQuartzBackgroundWorkerBase
{
private readonly CasPaymentRequestCoordinator _casPaymentRequestCoordinator;
@@ -42,7 +41,7 @@ CasPaymentRequestCoordinator casPaymentRequestCoordinator
}
}
- public override async Task Execute(IJobExecutionContext context)
+ protected override async Task ExecuteInBackgroundAsync(IJobExecutionContext context)
{
await _casPaymentRequestCoordinator.AddPaymentRequestsToReconciliationQueue();
await Task.CompletedTask;
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs
index 74f126b99a..f8a8c4c710 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs
@@ -13,6 +13,7 @@
using Unity.Payments.Events;
using Unity.Payments.RabbitMQ.QueueMessages;
using Unity.Notifications.Integrations.RabbitMQ;
+using Volo.Abp.Data;
using Volo.Abp.EventBus.Local;
namespace Unity.Payments.PaymentRequests
@@ -129,55 +130,151 @@ public async Task AddPaymentRequestsToReconciliationQueue()
return null;
}
- using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: true);
+ const int maxConcurrencyAttempts = 3;
+ CasPaymentValues? originalCasValues = null;
- var paymentRequest = await paymentRequestsRepository.GetAsync(PaymentRequestId);
- var previousPaymentStatus = paymentRequest.PaymentStatus;
- var previousStatus = paymentRequest.Status;
+ for (var attempt = 1; ; attempt++)
+ {
+ try
+ {
+ using var uow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: true);
+
+ var paymentRequest = await paymentRequestsRepository.GetAsync(PaymentRequestId);
+ var previousPaymentStatus = paymentRequest.PaymentStatus;
+ var previousStatus = paymentRequest.Status;
+ originalCasValues ??= GetCurrentCasValues(paymentRequest);
+
+ var casValues = GetCasValues(paymentRequest, result);
+ if (attempt > 1)
+ {
+ var currentCasValues = GetCurrentCasValues(paymentRequest);
+ if (currentCasValues == casValues)
+ {
+ return paymentRequest;
+ }
- UpdatePaymentRequestFromCasResult(paymentRequest, result);
+ if (currentCasValues != originalCasValues)
+ {
+ Logger.LogWarning(
+ "Rejecting stale CAS result for payment request {PaymentRequestId}; the payment has a different CAS state.",
+ PaymentRequestId);
+ return paymentRequest;
+ }
+ }
- await paymentRequestsRepository.UpdateAsync(paymentRequest, autoSave: false);
+ ApplyCasValues(paymentRequest, casValues);
- // CompleteAsync commits the transaction and calls SaveChangesAsync,
- // which triggers AbpDbContext to collect entity changes into the active audit log.
- // The audit log is then persisted by QueueConsumerHandler after ConsumeAsync returns.
- await uow.CompleteAsync();
+ await paymentRequestsRepository.UpdateAsync(paymentRequest, autoSave: false);
- if (previousPaymentStatus != paymentRequest.PaymentStatus || previousStatus != paymentRequest.Status)
- {
- await localEventBus.PublishAsync(new PaymentStatusChangedEvent
+ // CompleteAsync commits the transaction and calls SaveChangesAsync,
+ // which triggers AbpDbContext to collect entity changes into the active audit log.
+ // The audit log is then persisted by QueueConsumerHandler after ConsumeAsync returns.
+ await uow.CompleteAsync();
+
+ if (previousPaymentStatus != paymentRequest.PaymentStatus || previousStatus != paymentRequest.Status)
+ {
+ await localEventBus.PublishAsync(new PaymentStatusChangedEvent
+ {
+ PaymentRequestId = paymentRequest.Id,
+ ApplicationId = paymentRequest.CorrelationId,
+ Status = paymentRequest.Status,
+ CasPaymentStatus = paymentRequest.PaymentStatus,
+ TenantId = TenantId
+ });
+ }
+
+ return paymentRequest;
+ }
+ catch (AbpDbConcurrencyException ex) when (attempt < maxConcurrencyAttempts)
{
- PaymentRequestId = paymentRequest.Id,
- ApplicationId = paymentRequest.CorrelationId,
- Status = paymentRequest.Status,
- CasPaymentStatus = paymentRequest.PaymentStatus,
- TenantId = TenantId
- });
- }
+ using var inspectionUow = unitOfWorkManager.Begin(requiresNew: true, isTransactional: false);
+ var currentPaymentRequest = await paymentRequestsRepository.GetAsync(PaymentRequestId);
+ var currentCasValues = GetCurrentCasValues(currentPaymentRequest);
+ var desiredCasValues = GetCasValues(currentPaymentRequest, result);
- return paymentRequest;
+ if (currentCasValues == desiredCasValues)
+ {
+ return currentPaymentRequest;
+ }
+
+ if (currentCasValues != originalCasValues)
+ {
+ Logger.LogWarning(
+ ex,
+ "Rejecting stale CAS result for payment request {PaymentRequestId}; the payment has a different CAS state.",
+ PaymentRequestId);
+ return currentPaymentRequest;
+ }
+
+ Logger.LogWarning(
+ ex,
+ "Concurrency conflict updating payment request {PaymentRequestId} from CAS; retrying attempt {Attempt} of {MaxAttempts}.",
+ PaymentRequestId,
+ attempt + 1,
+ maxConcurrencyAttempts);
+ }
+ catch (AbpDbConcurrencyException)
+ {
+ throw;
+ }
+ }
}
- private static void UpdatePaymentRequestFromCasResult(PaymentRequest paymentRequest, CasPaymentSearchResult result)
+ private static CasPaymentValues GetCasValues(PaymentRequest paymentRequest, CasPaymentSearchResult result)
{
- // Handle duplicate NotFound status by appending "2"
- if (paymentRequest.InvoiceStatus == CasPaymentRequestStatus.NotFound &&
- result.InvoiceStatus == CasPaymentRequestStatus.NotFound)
+ var invoiceStatus = result.InvoiceStatus ?? string.Empty;
+ if (paymentRequest.InvoiceStatus == CasPaymentRequestStatus.NotFound + "2" &&
+ invoiceStatus == CasPaymentRequestStatus.NotFound)
{
- result.InvoiceStatus = CasPaymentRequestStatus.NotFound + "2";
+ invoiceStatus = paymentRequest.InvoiceStatus;
}
- paymentRequest.SetInvoiceStatus(result.InvoiceStatus ?? "");
- paymentRequest.SetPaymentStatus(result.PaymentStatus ?? "");
- paymentRequest.SetPaymentDate(result.PaymentDate ?? "");
- paymentRequest.SetPaymentNumber(result.PaymentNumber ?? "");
-
- if (result.InvoiceStatus != null)
+ if (paymentRequest.InvoiceStatus == CasPaymentRequestStatus.NotFound &&
+ invoiceStatus == CasPaymentRequestStatus.NotFound)
{
- paymentRequest.SetCasHttpStatusCode((int)System.Net.HttpStatusCode.OK);
- paymentRequest.SetCasResponse("SUCCEEDED");
+ invoiceStatus = CasPaymentRequestStatus.NotFound + "2";
}
+
+ return new CasPaymentValues(
+ invoiceStatus,
+ result.PaymentStatus ?? string.Empty,
+ result.PaymentNumber ?? string.Empty,
+ result.PaymentDate ?? string.Empty,
+ result.InvoiceStatus != null ? (int)System.Net.HttpStatusCode.OK : null,
+ result.InvoiceStatus != null ? "SUCCEEDED" : null);
}
+
+ private static CasPaymentValues GetCurrentCasValues(PaymentRequest paymentRequest)
+ {
+ return new CasPaymentValues(
+ paymentRequest.InvoiceStatus ?? string.Empty,
+ paymentRequest.PaymentStatus ?? string.Empty,
+ paymentRequest.PaymentNumber ?? string.Empty,
+ paymentRequest.PaymentDate ?? string.Empty,
+ paymentRequest.CasHttpStatusCode,
+ paymentRequest.CasResponse);
+ }
+
+ private static void ApplyCasValues(PaymentRequest paymentRequest, CasPaymentValues casValues)
+ {
+ paymentRequest.SetInvoiceStatus(casValues.InvoiceStatus);
+ paymentRequest.SetPaymentStatus(casValues.PaymentStatus);
+ paymentRequest.SetPaymentDate(casValues.PaymentDate);
+ paymentRequest.SetPaymentNumber(casValues.PaymentNumber);
+
+ if (casValues.CasHttpStatusCode.HasValue)
+ {
+ paymentRequest.SetCasHttpStatusCode(casValues.CasHttpStatusCode.Value);
+ paymentRequest.SetCasResponse(casValues.CasResponse ?? string.Empty);
+ }
+ }
+
+ private readonly record struct CasPaymentValues(
+ string InvoiceStatus,
+ string PaymentStatus,
+ string PaymentNumber,
+ string PaymentDate,
+ int? CasHttpStatusCode,
+ string? CasResponse);
}
}
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentApprovals/UpdatePaymentRequestStatus.cshtml.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentApprovals/UpdatePaymentRequestStatus.cshtml.cs
index 0568393a6e..c021fdba7f 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentApprovals/UpdatePaymentRequestStatus.cshtml.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentApprovals/UpdatePaymentRequestStatus.cshtml.cs
@@ -6,7 +6,6 @@
using System.Linq;
using System.Threading.Tasks;
using Unity.GrantManager.ApplicationForms;
-using Unity.Payments.Domain.PaymentRequests;
using Unity.Payments.Domain.Services;
using Unity.Payments.Domain.Shared;
using Unity.Payments.Enums;
@@ -28,7 +27,6 @@ public class UpdatePaymentRequestStatus(
IPaymentsManager paymentsManager,
IPaymentRequestAppService paymentRequestAppService,
IApplicationFormAppService applicationFormAppService,
- IPaymentRequestRepository paymentRepository,
IPaymentConfigurationAppService paymentConfigurationAppService,
IPermissionCheckerService permissionCheckerService,
PaymentIdsCacheService cacheService) : AbpPageModel
@@ -113,7 +111,7 @@ private async Task> BuildPaymentApprovalsAsync(List<
PaymentThreshold = 0m;
}
- var approvalModel = await CreateApprovalModel(payment);
+ var approvalModel = CreateApprovalModel(payment);
ValidateApprovalModel(approvalModel);
if (await VerifyPermissionsAsync(payment.Status, approvalModel))
@@ -125,10 +123,9 @@ private async Task> BuildPaymentApprovalsAsync(List<
return paymentApprovals;
}
- private async Task CreateApprovalModel(PaymentDetailsDto payment)
+ private PaymentsApprovalModel CreateApprovalModel(PaymentDetailsDto payment)
{
bool isL3ApprovalRequired = payment.Amount > PaymentThreshold;
- await UpdateExpenseApprovalsAsync(payment, isL3ApprovalRequired);
return new PaymentsApprovalModel
{
@@ -148,29 +145,6 @@ private async Task CreateApprovalModel(PaymentDetailsDto
};
}
- private async Task UpdateExpenseApprovalsAsync(PaymentDetailsDto payment, bool isL3ApprovalRequired)
- {
- if (payment.Status == PaymentRequestStatus.L2Pending)
- {
- var l3Approval = payment.ExpenseApprovals.FirstOrDefault(x => x.Type == ExpenseApprovalType.Level3);
- PaymentRequest paymentEntity = await paymentRepository.GetAsync(payment.Id);
- if (isL3ApprovalRequired && l3Approval == null)
- {
- paymentEntity.ExpenseApprovals.Add(new ExpenseApproval(Guid.NewGuid(), ExpenseApprovalType.Level3));
- }
- else if (!isL3ApprovalRequired && l3Approval != null)
- {
- var l3ApprovalEntity = paymentEntity.ExpenseApprovals.FirstOrDefault(x => x.Type == ExpenseApprovalType.Level3);
- if (l3ApprovalEntity != null)
- {
- paymentEntity.ExpenseApprovals.Remove(l3ApprovalEntity);
- }
- }
-
- await paymentRepository.UpdateAsync(paymentEntity);
- }
- }
-
private void ValidateApprovalModel(PaymentsApprovalModel request)
{
var validationResults = new List();
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequest_Constructor_Tests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequest_Constructor_Tests.cs
index 7a646fc933..cbbe6edcf2 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequest_Constructor_Tests.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequest_Constructor_Tests.cs
@@ -1,9 +1,9 @@
using Shouldly;
using System;
using System.ComponentModel;
+using System.Linq;
using Unity.Payments.Codes;
using Unity.Payments.Domain.Exceptions;
-using Unity.Payments.Domain.PaymentRequests;
using Unity.Payments.Enums;
using Unity.Payments.PaymentRequests;
using Volo.Abp;
@@ -51,6 +51,32 @@ public void NormalConstructor_WithValidData_Succeeds()
payment.ExpenseApprovals.Count.ShouldBe(2);
}
+ [Fact]
+ public void SetLevel3ApprovalRequired_AddsOnlyOneLevel3Slot()
+ {
+ var payment = new PaymentRequest(Guid.NewGuid(), BuildNormalDto());
+ var approvalId = Guid.NewGuid();
+
+ payment.SetLevel3ApprovalRequired(true, approvalId);
+ payment.SetLevel3ApprovalRequired(true, Guid.NewGuid());
+
+ payment.ExpenseApprovals.Count.ShouldBe(3);
+ payment.ExpenseApprovals.Single(x => x.Type == ExpenseApprovalType.Level3).Id.ShouldBe(approvalId);
+ }
+
+ [Fact]
+ public void SetLevel3ApprovalRequired_RemovesOnlyLevel3Slot()
+ {
+ var payment = new PaymentRequest(Guid.NewGuid(), BuildNormalDto());
+ payment.SetLevel3ApprovalRequired(true, Guid.NewGuid());
+
+ payment.SetLevel3ApprovalRequired(false, Guid.NewGuid());
+ payment.SetLevel3ApprovalRequired(false, Guid.NewGuid());
+
+ payment.ExpenseApprovals.Count.ShouldBe(2);
+ payment.ExpenseApprovals.Select(x => x.Type).ShouldBe([ExpenseApprovalType.Level1, ExpenseApprovalType.Level2]);
+ }
+
#endregion
#region Historical payment constructor
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/PaymentRequests/CasPaymentRequestCoordinatorTests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/PaymentRequests/CasPaymentRequestCoordinatorTests.cs
new file mode 100644
index 0000000000..c22d8869b0
--- /dev/null
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/PaymentRequests/CasPaymentRequestCoordinatorTests.cs
@@ -0,0 +1,190 @@
+using System;
+using System.Reflection;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using Shouldly;
+using Microsoft.Extensions.Logging.Abstractions;
+using Unity.Payments.Domain.PaymentRequests;
+using Unity.Payments.Integrations.Cas;
+using Unity.Notifications.Integrations.RabbitMQ;
+using Volo.Abp.Application.Services;
+using Volo.Abp.Data;
+using Volo.Abp.DependencyInjection;
+using Volo.Abp.EventBus.Local;
+using Volo.Abp.MultiTenancy;
+using Volo.Abp.TenantManagement;
+using Volo.Abp.Uow;
+using Xunit;
+
+namespace Unity.Payments.PaymentRequests;
+
+public class CasPaymentRequestCoordinatorTests
+{
+ private static readonly Guid TenantId = Guid.NewGuid();
+ private static readonly Guid PaymentRequestId = Guid.NewGuid();
+
+ [Fact]
+ public async Task UpdatePaymentRequestStatus_RetriesAfterConcurrencyConflict()
+ {
+ var repository = Substitute.For();
+ repository.GetAsync(PaymentRequestId)
+ .Returns(CreatePaymentRequest(), CreatePaymentRequest(), CreatePaymentRequest());
+ var unitOfWork = Substitute.For();
+ unitOfWork.CompleteAsync()
+ .Returns(Task.FromException(new AbpDbConcurrencyException()), Task.CompletedTask);
+ var coordinator = CreateCoordinator(repository, unitOfWork);
+
+ var result = await coordinator.UpdatePaymentRequestStatus(TenantId, PaymentRequestId, CreateCasResult());
+
+ result.ShouldNotBeNull();
+ result!.PaymentStatus.ShouldBe("Paid");
+ _ = repository.Received(2).UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any());
+ _ = unitOfWork.Received(2).CompleteAsync();
+ }
+
+ [Fact]
+ public async Task UpdatePaymentRequestStatus_ReturnsCurrentRequestWhenResultIsAlreadyApplied()
+ {
+ var repository = Substitute.For();
+ repository.GetAsync(PaymentRequestId)
+ .Returns(CreatePaymentRequest(), CreatePaymentRequest(paymentStatus: "Paid"));
+ var unitOfWork = Substitute.For();
+ unitOfWork.CompleteAsync().Returns(Task.FromException(new AbpDbConcurrencyException()));
+ var coordinator = CreateCoordinator(repository, unitOfWork);
+
+ var result = await coordinator.UpdatePaymentRequestStatus(TenantId, PaymentRequestId, CreateCasResult());
+
+ result!.PaymentStatus.ShouldBe("Paid");
+ _ = repository.Received(1).UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any());
+ _ = unitOfWork.Received(1).CompleteAsync();
+ }
+
+ [Fact]
+ public async Task UpdatePaymentRequestStatus_RejectsStaleCasResult()
+ {
+ var repository = Substitute.For();
+ repository.GetAsync(PaymentRequestId)
+ .Returns(CreatePaymentRequest(), CreatePaymentRequest(invoiceStatus: "Paid"));
+ var unitOfWork = Substitute.For();
+ unitOfWork.CompleteAsync().Returns(Task.FromException(new AbpDbConcurrencyException()));
+ var coordinator = CreateCoordinator(repository, unitOfWork);
+
+ var result = await coordinator.UpdatePaymentRequestStatus(TenantId, PaymentRequestId, CreateCasResult());
+
+ result!.InvoiceStatus.ShouldBe("Paid");
+ _ = repository.Received(1).UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any());
+ _ = unitOfWork.Received(1).CompleteAsync();
+ }
+
+ [Fact]
+ public async Task UpdatePaymentRequestStatus_RejectsStateChangedBeforeRetryLoad()
+ {
+ var repository = Substitute.For();
+ repository.GetAsync(PaymentRequestId)
+ .Returns(
+ CreatePaymentRequest(),
+ CreatePaymentRequest(),
+ CreatePaymentRequest(invoiceStatus: "Paid"));
+ var unitOfWork = Substitute.For();
+ unitOfWork.CompleteAsync().Returns(Task.FromException(new AbpDbConcurrencyException()));
+ var coordinator = CreateCoordinator(repository, unitOfWork);
+
+ var result = await coordinator.UpdatePaymentRequestStatus(TenantId, PaymentRequestId, CreateCasResult());
+
+ result!.InvoiceStatus.ShouldBe("Paid");
+ _ = repository.Received(1).UpdateAsync(Arg.Any(), Arg.Any(), Arg.Any());
+ _ = unitOfWork.Received(1).CompleteAsync();
+ }
+
+ [Fact]
+ public async Task UpdatePaymentRequestStatus_ThrowsAfterMaximumConcurrencyAttempts()
+ {
+ var repository = Substitute.For();
+ repository.GetAsync(PaymentRequestId)
+ .Returns(
+ CreatePaymentRequest(),
+ CreatePaymentRequest(),
+ CreatePaymentRequest(),
+ CreatePaymentRequest(),
+ CreatePaymentRequest());
+ var unitOfWork = Substitute.For();
+ unitOfWork.CompleteAsync().Returns(
+ Task.FromException(new AbpDbConcurrencyException()),
+ Task.FromException(new AbpDbConcurrencyException()),
+ Task.FromException(new AbpDbConcurrencyException()));
+ var coordinator = CreateCoordinator(repository, unitOfWork);
+
+ await Should.ThrowAsync(() =>
+ coordinator.UpdatePaymentRequestStatus(TenantId, PaymentRequestId, CreateCasResult()));
+
+ _ = unitOfWork.Received(3).CompleteAsync();
+ }
+
+ private static CasPaymentRequestCoordinator CreateCoordinator(
+ IPaymentRequestRepository repository,
+ IUnitOfWork unitOfWork)
+ {
+ var unitOfWorkManager = Substitute.For();
+ unitOfWorkManager.Begin(Arg.Any(), Arg.Any()).Returns(unitOfWork);
+
+ var coordinator = new CasPaymentRequestCoordinator(
+ paymentQueueService: null!,
+ paymentRequestsRepository: repository,
+ unitOfWorkManager: unitOfWorkManager,
+ tenantRepository: Substitute.For(),
+ currentTenant: Substitute.For(),
+ localEventBus: Substitute.For());
+
+ var lazyServiceProvider = Substitute.For();
+ lazyServiceProvider.LazyGetService(Arg.Any>())
+ .Returns(NullLogger.Instance);
+ typeof(ApplicationService)
+ .GetProperty("LazyServiceProvider", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
+ ?.SetValue(coordinator, lazyServiceProvider);
+ return coordinator;
+ }
+
+ private static PaymentRequest CreatePaymentRequest(
+ string? invoiceStatus = null,
+ string? paymentStatus = null)
+ {
+ var paymentRequest = new PaymentRequest(PaymentRequestId, new CreatePaymentRequestDto
+ {
+ InvoiceNumber = "INV-123",
+ Amount = 100,
+ PayeeName = "Supplier",
+ ContractNumber = "CON-123",
+ SupplierNumber = "SUP-123",
+ SupplierName = "Supplier",
+ CorrelationId = Guid.NewGuid(),
+ SiteId = Guid.NewGuid(),
+ AccountCodingId = Guid.NewGuid(),
+ BatchName = "Batch"
+ });
+
+ if (invoiceStatus != null)
+ {
+ paymentRequest.SetInvoiceStatus(invoiceStatus);
+ }
+
+ if (paymentStatus != null)
+ {
+ paymentRequest.SetPaymentStatus(paymentStatus);
+ }
+
+ return paymentRequest;
+ }
+
+ private static CasPaymentSearchResult CreateCasResult()
+ {
+ return new CasPaymentSearchResult
+ {
+ InvoiceStatus = "Paid",
+ PaymentStatus = "Paid",
+ PaymentNumber = "PAY-123",
+ PaymentDate = "2026-10-13"
+ };
+ }
+}
diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.js b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.js
index 6415c1f872..61d5074a12 100644
--- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.js
+++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfiguration/Default.js
@@ -1790,7 +1790,7 @@ $(function () {
if (viewName?.trim()) {
// Update the view name in the list item and show it
- $deleteViewListItem.html(`The associated database view (${viewName})`);
+ $deleteViewListItem.text(`The associated database view (${viewName})`);
$deleteViewListItem.show();
} else {
// Hide the view deletion item if no view exists
@@ -2347,4 +2347,4 @@ function getReportingErrorMessage(xhr, error, fallbackMessage = 'An error occurr
}
return (error && error !== 'parsererror') ? error : fallbackMessage;
-}
\ No newline at end of file
+}
diff --git a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfigurationViewStatus/Default.js b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfigurationViewStatus/Default.js
index ab3799dfcd..348759ef54 100644
--- a/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfigurationViewStatus/Default.js
+++ b/applications/Unity.GrantManager/modules/Unity.Reporting/src/Unity.Reporting.Web/Views/Shared/Components/ReportingConfigurationViewStatus/Default.js
@@ -151,7 +151,7 @@ $(function () {
Preview Mode:
- Showing sample data from the first application ID found in the view (${response.totalCount} record${response.totalCount !== 1 ? 's' : ''} from this application).
+ Showing sample data from the first application ID found in the view (${abp.utils.htmlEscape(response.totalCount)} record${response.totalCount !== 1 ? 's' : ''} from this application).
@@ -160,7 +160,7 @@ $(function () {
// Add column headers
response.columns.forEach(function(column) {
- tableHtml += `| ${column} | `;
+ tableHtml += `${abp.utils.htmlEscape(column)} | `;
});
tableHtml += '';
@@ -284,4 +284,4 @@ $(function () {
refreshViewStatusWidget();
}, 1000);
});
-});
\ No newline at end of file
+});
diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Constants/QueueingConstants.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Constants/QueueingConstants.cs
index 6e70f6c617..aab9837dba 100644
--- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Constants/QueueingConstants.cs
+++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Constants/QueueingConstants.cs
@@ -1,9 +1,21 @@
+using System;
+
namespace Unity.Modules.Shared.MessageBrokers.RabbitMQ.Constants
{
public static class QueueingConstants
{
public const string DeadletterAddition = "-deadletter";
+ public const string RetryAddition = ".retry";
+ public const string RetryCountHeader = "x-retry-count";
+ public const int MaxMessageRetries = 3;
public const int MAX_RABBIT_CONCURRENT_CONSUMERS = 10;
+
+ public static readonly TimeSpan[] RetryDelays =
+ [
+ TimeSpan.FromSeconds(30),
+ TimeSpan.FromMinutes(2),
+ TimeSpan.FromMinutes(10)
+ ];
}
}
diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueChannelProvider.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueChannelProvider.cs
index 1baa6ebc8c..d161313cb3 100644
--- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueChannelProvider.cs
+++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/Interfaces/IQueueChannelProvider.cs
@@ -1,6 +1,8 @@
using System;
+using System.Threading;
using System.Threading.Tasks;
using RabbitMQ.Client;
+using RabbitMQ.Client.Events;
#pragma warning disable CA1005 // Avoid excessive parameters on generic types
#pragma warning disable S2326
@@ -16,6 +18,14 @@ public interface IQueueChannelProvider : IDisposable where TQueue
///
Task GetChannelAsync();
+ ///
+ /// Republishes a failed delivery to a delayed retry queue. The returned task completes
+ /// only after RabbitMQ confirms the retry publish. Returns true when the retry handoff
+ /// was accepted by the broker; false if the original delivery was requeued or channel recovery
+ /// was arranged due to a publishing failure.
+ ///
+ Task PublishRetryAsync(IChannel channel, BasicDeliverEventArgs delivery, int retryCount, TimeSpan delay, CancellationToken cancellationToken = default);
+
///
/// Returns a channel obtained from so it can be pooled
/// or disposed and its throttling permit released. Callers that finish with a channel
diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs
index 9be537782d..5210f5b5a8 100644
--- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs
+++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueChannelProvider.cs
@@ -1,10 +1,11 @@
-using System;
+using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using RabbitMQ.Client;
+using RabbitMQ.Client.Events;
using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Constants;
using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces;
@@ -60,9 +61,8 @@ public async Task GetChannelAsync()
}
// Create new channel
- var channel = await _channelProvider.GetChannelAsync() ?? throw new InvalidOperationException("Channel cannot be null.");
- await EnsureQueueDeclaredAsync(channel);
- return channel;
+ await EnsureQueueDeclaredAsync();
+ return await _channelProvider.GetChannelAsync() ?? throw new InvalidOperationException("Channel cannot be null.");
}
catch
{
@@ -71,6 +71,92 @@ public async Task GetChannelAsync()
}
}
+ public async Task PublishRetryAsync(
+ IChannel channel,
+ BasicDeliverEventArgs delivery,
+ int retryCount,
+ TimeSpan delay,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(channel);
+ ArgumentNullException.ThrowIfNull(delivery);
+
+ var retryExchange = $"{_queueName}{QueueingConstants.RetryAddition}";
+ var retryQueue = $"{retryExchange}.{GetRetryDelayKey(delay)}";
+ var properties = new BasicProperties
+ {
+ Persistent = true,
+ Type = delivery.BasicProperties.Type,
+ MessageId = delivery.BasicProperties.MessageId,
+ Headers = new Dictionary
+ {
+ [QueueingConstants.RetryCountHeader] = retryCount
+ }
+ };
+
+ // Use mandatory=true so unroutable publishes are reported by publisher-confirms.
+ // Retry publish failures are handled explicitly: retry with backoff, and if all attempts fail
+ // requeue the original delivery (or close the channel to force broker redelivery) so the
+ // message is not lost.
+ var attempts = 0;
+ const int maxAttempts = 3;
+ var backoff = TimeSpan.FromMilliseconds(100);
+ while (true)
+ {
+ attempts++;
+ try
+ {
+ var publisherChannel = await GetChannelAsync();
+ try
+ {
+ await publisherChannel.BasicPublishAsync(
+ exchange: retryExchange,
+ routingKey: retryQueue,
+ mandatory: true,
+ basicProperties: properties,
+ body: delivery.Body,
+ cancellationToken: cancellationToken);
+ }
+ finally
+ {
+ ReturnChannel(publisherChannel);
+ }
+
+ return true;
+ }
+ catch (Exception ex) when (attempts < maxAttempts)
+ {
+ _logger.LogWarning(ex, "Retry publish attempt {Attempt} failed for message {MessageId}; backing off {Backoff} before retrying.", attempts, delivery.BasicProperties?.MessageId, backoff);
+ try
+ {
+ await Task.Delay(backoff, cancellationToken);
+ }
+ catch (OperationCanceledException) { }
+ backoff = TimeSpan.FromTicks(backoff.Ticks * 2);
+ continue;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Retry publish failed for message {MessageId}; attempting to requeue original delivery.", delivery.BasicProperties?.MessageId);
+ try
+ {
+ // Requeue the original delivery so it will be redelivered when the broker recovers.
+ await channel.BasicNackAsync(delivery.DeliveryTag, multiple: false, requeue: true);
+ }
+ catch (Exception nackEx)
+ {
+ // BasicNack failure means the channel is compromised. Do NOT close it here - if it's an active consumer channel,
+ // closing it will interrupt message delivery. Log the error and return false to signal failure; the consumer
+ // handler or connection recovery will handle channel state. The broker will eventually redeliver the message
+ // via its internal recovery mechanisms even if this immediate nack fails.
+ _logger.LogError(nackEx, "Failed to BasicNack original delivery after retry publish failure for message {MessageId}. Channel may require recovery by consumer handler.", delivery.BasicProperties?.MessageId);
+ }
+
+ return false;
+ }
+ }
+ }
+
public void ReturnChannel(IChannel channel)
{
if (channel?.IsOpen == true && !_disposed)
@@ -93,7 +179,7 @@ public void ReturnChannel(IChannel channel)
}
}
- private async Task EnsureQueueDeclaredAsync(IChannel channel)
+ private async Task EnsureQueueDeclaredAsync()
{
if (_queueDeclared) return;
@@ -102,7 +188,7 @@ private async Task EnsureQueueDeclaredAsync(IChannel channel)
{
if (_queueDeclared) return;
- await DeclareQueueAsync(channel);
+ await DeclareQueueAsync();
_queueDeclared = true;
}
catch (Exception ex)
@@ -116,62 +202,144 @@ private async Task EnsureQueueDeclaredAsync(IChannel channel)
}
}
- private async Task DeclareQueueAsync(IChannel channel)
+ // Every declaration runs on its own short-lived channel: a 406 PRECONDITION_FAILED closes the
+ // channel it happened on, so it must never be a channel handed back to a publisher or consumer,
+ // nor one reused for the next declaration.
+ private async Task DeclareQueueAsync()
{
+ var dlxName = $"{_queueName}.dlx";
+ var dlqName = $"{_queueName}{QueueingConstants.DeadletterAddition}";
+
+ // Main topology (DLX, DLQ, main queue) is required for publishing and consuming.
try
{
- var dlxName = $"{_queueName}.dlx";
- var dlqName = $"{_queueName}{QueueingConstants.DeadletterAddition}";
+ await WithDeclarationChannelAsync(async channel =>
+ {
+ await channel.ExchangeDeclareAsync(dlxName, ExchangeType.Direct, durable: true, autoDelete: false, arguments: null);
+
+ await channel.QueueDeclareAsync(dlqName, durable: true, exclusive: false, autoDelete: false,
+ arguments: new Dictionary
+ {
+ { "x-queue-type", "quorum" },
+ { "x-overflow", "reject-publish" }
+ });
+ await channel.QueueBindAsync(dlqName, dlxName, dlqName, arguments: null);
+
+ await channel.QueueDeclareAsync(
+ _queueName,
+ durable: true,
+ exclusive: false,
+ autoDelete: false,
+ arguments: new Dictionary
+ {
+ { "x-queue-type", "quorum" },
+ { "x-overflow", "reject-publish" },
+ { "x-dead-letter-exchange", dlxName },
+ { "x-dead-letter-routing-key", dlqName },
+ { "x-dead-letter-strategy", "at-least-once" },
+ { "x-delivery-limit", 10 }
+ });
+ });
+ }
+ catch (global::RabbitMQ.Client.Exceptions.OperationInterruptedException ex) when (IsInequivalentArgs(ex))
+ {
+ _logger.LogWarning(
+ ex,
+ "Queue {QueueName} exists with incompatible config. Using existing queue in compatibility mode.",
+ _queueName);
+ }
- // Ensure DLX exchange exists
- await channel.ExchangeDeclareAsync(dlxName, ExchangeType.Direct, durable: true, autoDelete: false, arguments: null);
+ await WithDeclarationChannelAsync(BindToExchangeAsync);
- // Ensure DLQ exists and is bound to DLX
- await channel.QueueDeclareAsync(dlqName, durable: true, exclusive: false, autoDelete: false,
- arguments: new Dictionary
- {
- { "x-queue-type", "quorum" },
- { "x-overflow", "reject-publish" }
- });
- await channel.QueueBindAsync(dlqName, dlxName, dlqName, arguments: null);
-
- // Declare main queue with DLX args
- await channel.QueueDeclareAsync(
- _queueName,
- durable: true,
- exclusive: false,
- autoDelete: false,
- arguments: new Dictionary
- {
- { "x-queue-type", "quorum" },
- { "x-overflow", "reject-publish" },
- { "x-dead-letter-exchange", dlxName },
- { "x-dead-letter-routing-key", dlqName },
- { "x-dead-letter-strategy", "at-least-once" },
- { "x-delivery-limit", 10 }
- });
+ // Retry topology is best-effort: a mismatch here must not block the main publish/consume path.
+ await DeclareRetryQueuesAsync();
+ }
+
+ private async Task DeclareRetryQueuesAsync()
+ {
+ var retryExchange = $"{_queueName}{QueueingConstants.RetryAddition}";
- await BindToExchangeAsync(channel);
+ try
+ {
+ await WithDeclarationChannelAsync(channel =>
+ channel.ExchangeDeclareAsync(retryExchange, ExchangeType.Direct, durable: true, autoDelete: false, arguments: null));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to declare retry exchange {RetryExchange}; failed messages cannot be scheduled for retry.", retryExchange);
+ return;
}
- catch (global::RabbitMQ.Client.Exceptions.OperationInterruptedException ex)
+
+ foreach (var retryDelay in QueueingConstants.RetryDelays)
{
- if (ex.ShutdownReason?.ReplyCode == 406 &&
- ex.ShutdownReason.ReplyText.Contains("inequivalent arg"))
+ var retryQueue = $"{retryExchange}.{GetRetryDelayKey(retryDelay)}";
+ try
+ {
+ await WithDeclarationChannelAsync(async channel =>
+ {
+ await channel.QueueDeclareAsync(
+ retryQueue,
+ durable: true,
+ exclusive: false,
+ autoDelete: false,
+ arguments: new Dictionary
+ {
+ { "x-queue-type", "quorum" },
+ { "x-message-ttl", checked((int)retryDelay.TotalMilliseconds) },
+ { "x-dead-letter-exchange", $"{_queueName}.exchange" },
+ { "x-dead-letter-routing-key", _queueName },
+ { "x-overflow", "reject-publish" },
+ { "x-dead-letter-strategy", "at-least-once" }
+ });
+ });
+ }
+ catch (global::RabbitMQ.Client.Exceptions.OperationInterruptedException ex) when (IsInequivalentArgs(ex))
{
_logger.LogWarning(
ex,
- "Queue {QueueName} exists with incompatible config. Using existing queue in compatibility mode.",
- _queueName);
+ "Retry queue {RetryQueue} exists with incompatible config; using it as-is. Delete the queue to have it redeclared with current arguments.",
+ retryQueue);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to declare retry queue {RetryQueue}.", retryQueue);
+ continue;
+ }
- await BindToExchangeAsync(channel);
+ try
+ {
+ await WithDeclarationChannelAsync(channel =>
+ channel.QueueBindAsync(retryQueue, retryExchange, retryQueue, arguments: null));
}
- else
+ catch (Exception ex)
{
- throw;
+ _logger.LogError(ex, "Failed to bind retry queue {RetryQueue} to {RetryExchange}.", retryQueue, retryExchange);
}
}
}
+ private async Task WithDeclarationChannelAsync(Func declare)
+ {
+ var channel = await _channelProvider.GetChannelAsync()
+ ?? throw new InvalidOperationException($"Unable to obtain a channel to declare topology for queue {_queueName}.");
+ try
+ {
+ await declare(channel);
+ }
+ finally
+ {
+ // The underlying provider disposes the channel instead of pooling it if a 406 closed it.
+ _channelProvider.ReturnChannel(channel);
+ }
+ }
+
+ private static bool IsInequivalentArgs(global::RabbitMQ.Client.Exceptions.OperationInterruptedException ex) =>
+ ex.ShutdownReason?.ReplyCode == 406 &&
+ ex.ShutdownReason.ReplyText.Contains("inequivalent arg");
+
+ private static string GetRetryDelayKey(TimeSpan delay) =>
+ $"{(int)delay.TotalSeconds}s";
+
private async Task BindToExchangeAsync(IChannel channel)
{
var mainExchange = $"{_queueName}.exchange";
diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs
index 37e07c7048..7be8bc66a7 100644
--- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs
+++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/MessageBrokers.RabbitMQ/QueueConsumerHandler.cs
@@ -1,12 +1,13 @@
using System;
+using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
-using RabbitMQ.Client;
using RabbitMQ.Client.Events;
using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Exceptions;
+using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Constants;
using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces;
using Unity.Modules.Shared.Utils;
using Volo.Abp.Auditing;
@@ -126,9 +127,73 @@ private async Task HandleMessageAsync(object sender, BasicDeliverEventArgs ea)
}
catch (Exception ex)
{
- _logger.LogError(ex, "Error processing message on {Queue}", _queueName);
- await consumingChannel.BasicRejectAsync(ea.DeliveryTag, requeue: false);
+ var retryCount = GetRetryCount(ea.BasicProperties.Headers);
+ if (IsPermanentFailure(ex) || retryCount >= QueueingConstants.MaxMessageRetries)
+ {
+ _logger.LogError(ex,
+ "Permanently rejecting message {MessageId} on {Queue} after {RetryCount} retries",
+ ea.BasicProperties.MessageId,
+ _queueName,
+ retryCount);
+ await consumingChannel.BasicRejectAsync(ea.DeliveryTag, requeue: false);
+ return;
+ }
+
+ var nextRetryCount = retryCount + 1;
+ var delay = QueueingConstants.RetryDelays[retryCount];
+ var channelProvider = consumerScope.ServiceProvider
+ .GetRequiredService>();
+
+ _logger.LogWarning(ex,
+ "Scheduling retry {RetryCount} for message {MessageId} on {Queue} in {Delay}",
+ nextRetryCount,
+ ea.BasicProperties.MessageId,
+ _queueName,
+ delay);
+
+ var retryAccepted = await channelProvider.PublishRetryAsync(consumingChannel, ea, nextRetryCount, delay);
+ if (retryAccepted)
+ {
+ await consumingChannel.BasicAckAsync(ea.DeliveryTag, multiple: false);
+ }
+ else
+ {
+ // Retry publish failed; the provider requeued the original delivery or arranged channel recovery.
+ // Do not acknowledge the original delivery here so it will be redelivered.
+ }
+ }
+ }
+
+ private static int GetRetryCount(IDictionary? headers)
+ {
+ if (headers == null || !headers.TryGetValue(QueueingConstants.RetryCountHeader, out var value))
+ {
+ return 0;
+ }
+
+ return value switch
+ {
+ int count => Math.Max(count, 0),
+ long count => Math.Max((int)count, 0),
+ byte[] bytes when int.TryParse(Encoding.UTF8.GetString(bytes), out var count) => Math.Max(count, 0),
+ _ when int.TryParse(value?.ToString(), out var count) => Math.Max(count, 0),
+ _ => 0
+ };
+ }
+
+ private static bool IsPermanentFailure(Exception exception)
+ {
+ for (var current = exception; current != null; current = current.InnerException)
+ {
+ if (current is ArgumentException ||
+ current is Volo.Abp.Validation.AbpValidationException ||
+ current is Volo.Abp.Domain.Entities.EntityNotFoundException)
+ {
+ return true;
+ }
}
+
+ return false;
}
///
@@ -137,7 +202,7 @@ private async Task HandleMessageAsync(object sender, BasicDeliverEventArgs ea)
/// audit persistence are handled here so individual consumers stay free of
/// infrastructure concerns.
///
- private static async Task ConsumeWithAuditingAsync(IServiceScope consumerScope, ITenantedQueueMessage tenantedMessage, TQueueMessage message)
+ private async Task ConsumeWithAuditingAsync(IServiceScope consumerScope, ITenantedQueueMessage tenantedMessage, TQueueMessage message)
{
var auditingManager = consumerScope.ServiceProvider.GetRequiredService();
var principalAccessor = consumerScope.ServiceProvider.GetRequiredService();
@@ -157,7 +222,16 @@ private static async Task ConsumeWithAuditingAsync(IServiceScope consumerScope,
// so this call happens after the consumer's unit of work completes.
if (auditingManager.Current?.Log is { EntityChanges.Count: > 0 } log)
{
- await auditingStore.SaveAsync(log);
+ try
+ {
+ await auditingStore.SaveAsync(log);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex,
+ "Failed to persist audit log for message {MessageId}; consumer work was already committed.",
+ message.MessageId);
+ }
}
}
}
diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Unity.SharedKernel.csproj b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Unity.SharedKernel.csproj
index bc3d9950fd..113692d930 100644
--- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Unity.SharedKernel.csproj
+++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Unity.SharedKernel.csproj
@@ -1,4 +1,4 @@
-
+
@@ -19,6 +19,7 @@
+
diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundExecutionAuthorizationHandler.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundExecutionAuthorizationHandler.cs
new file mode 100644
index 0000000000..0ffec4ba42
--- /dev/null
+++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundExecutionAuthorizationHandler.cs
@@ -0,0 +1,27 @@
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Authorization;
+
+namespace Unity.Modules.Shared.Utils;
+
+public static class BackgroundExecutionPolicy
+{
+ public const string Name = "Unity.BackgroundExecution";
+}
+
+public sealed class BackgroundExecutionRequirement : IAuthorizationRequirement;
+
+public sealed class BackgroundExecutionAuthorizationHandler
+ : AuthorizationHandler
+{
+ protected override Task HandleRequirementAsync(
+ AuthorizationHandlerContext context,
+ BackgroundExecutionRequirement requirement)
+ {
+ if (BackgroundJobExecutionContext.IsActive)
+ {
+ context.Succeed(requirement);
+ }
+
+ return Task.CompletedTask;
+ }
+}
\ No newline at end of file
diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobContext.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobContext.cs
index 78ce8c6609..46370e100f 100644
--- a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobContext.cs
+++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/BackgroundJobContext.cs
@@ -21,21 +21,19 @@ public static class BackgroundJobContext
/// The current principal accessor for setting user identity
/// The current tenant accessor for setting tenant context
/// The tenant ID to set context for
- /// Optional user ID. If null, uses BackgroundJobConstants.BackgroundJobPersonId
/// IDisposable that restores previous context when disposed (LIFO order)
public static IDisposable Set(
IAuditingManager auditingManager,
ICurrentPrincipalAccessor principalAccessor,
ICurrentTenant currentTenant,
- Guid? tenantId,
- Guid? userId = null)
+ Guid? tenantId)
{
- var effectiveUserId = userId ?? BackgroundJobConstants.BackgroundJobPersonId;
+ var backgroundJobPersonId = BackgroundJobConstants.BackgroundJobPersonId;
var claims = new[]
{
- new Claim(AbpClaimTypes.UserId, effectiveUserId.ToString()),
- new Claim(ClaimTypes.NameIdentifier, effectiveUserId.ToString()), // Standard claim for user ID
+ new Claim(AbpClaimTypes.UserId, backgroundJobPersonId.ToString()),
+ new Claim(ClaimTypes.NameIdentifier, backgroundJobPersonId.ToString()), // Standard claim for user ID
new Claim(AbpClaimTypes.UserName, BackgroundJobConstants.BackgroundJobUserName),
new Claim(AbpClaimTypes.Email, BackgroundJobConstants.BackgroundJobEmail),
new Claim(AbpClaimTypes.TenantId, tenantId?.ToString() ?? Guid.Empty.ToString()),
@@ -52,20 +50,49 @@ public static IDisposable Set(
var principalDisposable = principalAccessor.Change(principal);
// NOW start auditing - it will see the correct tenant/user context
+ var auditingDisposable = BeginAuditScope(auditingManager, tenantId);
+
+ // Dispose in LIFO order: last registered is disposed first.
+ // audit scope closes first (while tenant/user context is still valid),
+ // then principal, then tenant.
+ return new CompositeDisposable(tenantDisposable, principalDisposable, auditingDisposable);
+ }
+
+ public static IDisposable SetExecutionContext(
+ ICurrentPrincipalAccessor principalAccessor,
+ ICurrentTenant currentTenant,
+ Guid? tenantId)
+ {
+ var backgroundJobPersonId = BackgroundJobConstants.BackgroundJobPersonId;
+ var claims = new[]
+ {
+ new Claim(AbpClaimTypes.UserId, backgroundJobPersonId.ToString()),
+ new Claim(ClaimTypes.NameIdentifier, backgroundJobPersonId.ToString()),
+ new Claim(AbpClaimTypes.UserName, BackgroundJobConstants.BackgroundJobUserName),
+ new Claim(AbpClaimTypes.Email, BackgroundJobConstants.BackgroundJobEmail),
+ new Claim(AbpClaimTypes.TenantId, tenantId?.ToString() ?? Guid.Empty.ToString()),
+ new Claim(AbpClaimTypes.Name, BackgroundJobConstants.BackgroundJobName)
+ };
+
+ var principal = new ClaimsPrincipal(new ClaimsIdentity(
+ claims, "BackgroundJob", AbpClaimTypes.UserName, AbpClaimTypes.Role));
+ var tenantDisposable = currentTenant.Change(tenantId);
+ var principalDisposable = principalAccessor.Change(principal);
+ return new CompositeDisposable(tenantDisposable, principalDisposable);
+ }
+
+ public static IDisposable BeginAuditScope(IAuditingManager auditingManager, Guid? tenantId)
+ {
var auditingDisposable = auditingManager.BeginScope();
-
- // Ensure the current audit log has the user ID set for entity change tracking
+
if (auditingManager.Current != null)
{
- auditingManager.Current.Log.UserId = effectiveUserId;
+ auditingManager.Current.Log.UserId = BackgroundJobConstants.BackgroundJobPersonId;
auditingManager.Current.Log.UserName = BackgroundJobConstants.BackgroundJobUserName;
auditingManager.Current.Log.TenantId = tenantId;
}
- // Dispose in LIFO order: last registered is disposed first.
- // audit scope closes first (while tenant/user context is still valid),
- // then principal, then tenant.
- return new CompositeDisposable(auditingDisposable, principalDisposable, tenantDisposable);
+ return auditingDisposable;
}
///
diff --git a/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/UnityQuartzBackgroundWorkerBase.cs b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/UnityQuartzBackgroundWorkerBase.cs
new file mode 100644
index 0000000000..163d648d59
--- /dev/null
+++ b/applications/Unity.GrantManager/modules/Unity.SharedKernel/Utils/UnityQuartzBackgroundWorkerBase.cs
@@ -0,0 +1,109 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Quartz;
+using Volo.Abp.BackgroundWorkers.Quartz;
+using Volo.Abp.Auditing;
+using Volo.Abp.MultiTenancy;
+using Volo.Abp.Security.Claims;
+
+namespace Unity.Modules.Shared.Utils;
+
+///
+/// Establishes the trusted execution context for every Quartz background worker.
+///
+public abstract class UnityQuartzBackgroundWorkerBase : QuartzBackgroundWorkerBase
+{
+ protected virtual bool UsesTenantAuditScopes => false;
+
+ public sealed override async Task Execute(IJobExecutionContext context)
+ {
+ var auditingManager = ServiceProvider.GetRequiredService();
+ var auditingStore = ServiceProvider.GetRequiredService();
+ var principalAccessor = ServiceProvider.GetRequiredService();
+ var currentTenant = ServiceProvider.GetRequiredService();
+
+ using (BackgroundJobExecutionContext.Use())
+ {
+ if (UsesTenantAuditScopes)
+ {
+ using (BackgroundJobContext.SetExecutionContext(principalAccessor, currentTenant, tenantId: null))
+ {
+ await ExecuteInBackgroundAsync(context);
+ }
+ return;
+ }
+
+ using (BackgroundJobContext.Set(auditingManager, principalAccessor, currentTenant, tenantId: null))
+ {
+ AddWorkerAuditAction(auditingManager, context);
+ await ExecuteInBackgroundAsync(context);
+
+ if (auditingManager.Current?.Log is { EntityChanges.Count: > 0 } log)
+ {
+ try
+ {
+ using (currentTenant.Change(null))
+ {
+ await auditingStore.SaveAsync(log);
+ }
+ }
+ catch (Exception ex)
+ {
+ // Audit persistence failures after transaction commit should not cause worker to retry.
+ // The business transaction already succeeded; audit logging is non-critical.
+ Logger.LogWarning(ex, "Failed to persist audit log for background worker {WorkerName}; worker work was already committed (not retrying).", GetType().Name);
+ }
+ }
+ }
+ }
+ }
+
+ protected async Task ExecuteInTenantAuditScopeAsync(Guid? tenantId, Func action)
+ {
+ var auditingManager = ServiceProvider.GetRequiredService();
+ var auditingStore = ServiceProvider.GetRequiredService();
+ var currentTenant = ServiceProvider.GetRequiredService();
+ var principalAccessor = ServiceProvider.GetRequiredService();
+
+ using (BackgroundJobContext.Set(auditingManager, principalAccessor, currentTenant, tenantId))
+ {
+ AddWorkerAuditAction(auditingManager, null);
+ await action();
+
+ if (auditingManager.Current?.Log is { EntityChanges.Count: > 0 } log)
+ {
+ try
+ {
+ await auditingStore.SaveAsync(log);
+ }
+ catch (Exception ex)
+ {
+ // Audit persistence failures after transaction commit should not cause worker to retry.
+ // The business transaction already succeeded; audit logging is non-critical.
+ Logger.LogWarning(ex, "Failed to persist audit log for tenant {TenantId}; worker work was already committed (not retrying).", tenantId);
+ }
+ }
+ }
+ }
+
+ private void AddWorkerAuditAction(IAuditingManager auditingManager, IJobExecutionContext? context)
+ {
+ if (auditingManager.Current?.Log == null)
+ {
+ return;
+ }
+
+ auditingManager.Current.Log.Actions.Add(new AuditLogActionInfo
+ {
+ ServiceName = GetType().FullName ?? GetType().Name,
+ MethodName = nameof(ExecuteInBackgroundAsync),
+ Parameters = context == null ? "{}" : System.Text.Json.JsonSerializer.Serialize(new { context.FireInstanceId }),
+ ExecutionTime = DateTime.UtcNow
+ });
+ }
+
+ protected abstract Task ExecuteInBackgroundAsync(IJobExecutionContext context);
+}
\ No newline at end of file
diff --git a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.js b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.js
index 00f7626505..226c1a0531 100644
--- a/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.js
+++ b/applications/Unity.GrantManager/modules/Unity.TenantManagement/src/Unity.TenantManagement.Web/Pages/TenantManagement/Tenants/Index.js
@@ -423,13 +423,13 @@
}
function _renderFeatureItem(feature) {
- let id = 'ft-' + feature.name.replaceAll('.', '-');
+ let id = abp.utils.htmlEscape('ft-' + feature.name.replaceAll('.', '-'));
let checked = (feature.value || '').toLowerCase() === 'true' ? ' checked' : '';
return '' +
'' +
+ ' data-feature-name="' + abp.utils.htmlEscape(feature.name) + '"' + checked + '>' +
'' +
+ abp.utils.htmlEscape(feature.displayName || feature.name) + '' +
'
';
}
@@ -439,8 +439,8 @@
}
let html = '';
groups.forEach(function (group) {
- html += '';
- html += '
' + (group.displayName || group.name) + '
';
+ html += '
';
+ html += '
' + abp.utils.htmlEscape(group.displayName || group.name) + '
';
group.features?.forEach(function (feature) {
html += _renderFeatureItem(feature);
});
diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Components/Toolbar/UserMenu/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Components/Toolbar/UserMenu/Default.cshtml
index c6832c41e0..674914101b 100644
--- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Components/Toolbar/UserMenu/Default.cshtml
+++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Components/Toolbar/UserMenu/Default.cshtml
@@ -9,14 +9,11 @@
@model ApplicationMenu
@if (Model.Items.Any())
diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Components/Topbar/Default.cshtml b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Components/Topbar/Default.cshtml
index 60d6f40bab..174551f547 100644
--- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Components/Topbar/Default.cshtml
+++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Components/Topbar/Default.cshtml
@@ -25,7 +25,7 @@
- @Html.Raw(CurrentUser.FindClaimValue("Badge"))
+ @CurrentUser.FindClaimValue("Badge")
diff --git a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Layouts/Account.cshtml b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Layouts/Account.cshtml
index 3db6e18f52..67a15acd2e 100644
--- a/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Layouts/Account.cshtml
+++ b/applications/Unity.GrantManager/modules/Unity.Theme.UX2/src/Unity.Theme.UX2/Themes/UX2/Layouts/Account.cshtml
@@ -1,6 +1,8 @@
@using Unity.AspNetCore.Mvc.UI.Theme.UX2.Analytics
+@using System.Globalization
@using Microsoft.Extensions.Localization
@using Microsoft.Extensions.Options
+@using Microsoft.Extensions.Hosting
@using Unity.AspNetCore.Mvc.UI.Theme.UX2.Bundling
@using Unity.AspNetCore.Mvc.UI.Theme.UX2.Themes.UX2.Components.MainNavbar
@@ -18,6 +20,7 @@
@using Volo.Abp.Ui.LayoutHooks
@inject IServiceProvider ServiceProvider
+@inject IHostEnvironment HostEnvironment
@inject IBrandingProvider BrandingProvider
@inject IOptions
MultiTenancyOptions
@inject ICurrentTenant CurrentTenant
@@ -119,5 +122,9 @@
}
@await Component.InvokeLayoutHookAsync(LayoutHooks.Body.Last, StandardLayouts.Account)
+ @if (HostEnvironment.IsDevelopment())
+ {
+
+ }