+ Swal.fire(swalOptions).then(function(result) {
+ handleDeleteConfirmation(result, id, wrapperId);
+ });
+ }
+
+ function handleDeleteConfirmation(result, id, wrapperId) {
+ if (!result.isConfirmed) return;
+ deleteTemplate(id, wrapperId);
+ }
+
+ function handleDeleteSuccess(wrapperId) {
+ return function() {
+ $(`#${wrapperId}`).remove();
+ abp.notify.success('Template deleted successfully.');
+ };
+ }
+
+ function handleDeleteError() {
+ abp.notify.error('Error deleting the template.');
+ }
+
+ function deleteTemplate(id, wrapperId) {
+ $.ajax({
+ url: `/api/app/template/${id}/template`,
+ type: 'DELETE',
+ success: handleDeleteSuccess(wrapperId),
+ error: handleDeleteError
+ });
+ }
+
+ // Helper functions moved outside of createCard to reduce complexity
+ function getCardLastEditedDate(data) {
+ if (data?.lastModificationTime) {
+ return new Date(data.lastModificationTime).toLocaleDateString('en-CA');
+ } else if (data?.creationTime) {
+ return new Date(data.creationTime).toLocaleDateString('en-CA');
+ } else {
+ return new Date().toLocaleDateString('en-CA');
+ }
+ }
+
+ function generateCardHtml(cardConfig) {
+ const {
+ data,
+ elementIds,
+ displayInfo,
+ isPopulated
+ } = cardConfig;
+
+ const disabled = isPopulated ? 'disabled' : '';
+ const cardDataId = data?.id?.toString() || elementIds.wrapperId;
+
+ return `
+
-
`;
+ }
- $("#cardContainer").append(cardHtml);
- $(`#${wrapperId}`).data('original-name', data?.name || '');
- // editorInstances[id] =
- console.log("tinymce", tinymce)
- if (tinymce.get(editorId)) {
- tinymce.get(editorId).remove(); // remove existing instance
- }
-
- function getToolbarOptions() {
- return 'undo redo | styles | bold italic | alignleft aligncenter alignright alignjustify | bullist numlist | link image | code preview | variablesDropdownButton';
- }
-
- function getPlugins() {
- return 'lists link image preview code';
- }
-
-
- function setupEditor(editor, id, editorId, data, isPopulated) {
- editor.ui.registry.addMenuButton('variablesDropdownButton', {
- text: 'VARIABLES',
- fetch: function (callback) {
- const items = dropdownItems.map(item => ({
- type: 'menuitem',
- text: item.text,
- onAction: () => {
- editor.insertContent(`{{${item.value}}}`);
- }
- }));
- callback(items);
- }
- });
-
- editor.on('init', function () {
- editor.mode.set(isPopulated ? 'readonly' : 'design');
- if (data?.bodyHTML) {
- editor.setContent(data.bodyHTML);
- }
- editorInstances[id] = editor;
- console.log(`Editor initialized: ${editorId}`);
- });
- }
-
- function initTinyMCE(editorId, id, data, isPopulated) {
- tinymce.init({
- license_key: 'gpl',
- selector: `#${editorId}`,
- plugins: getPlugins(),
- toolbar: getToolbarOptions(),
- statusbar: false,
- promotion: false,
- content_css: false,
- skin: false,
- setup: function (editor) {
- console.log("editor", editor);
- setupEditor(editor, id, editorId, data, isPopulated);
- }
- });
- }
-
-
- initTinyMCE(editorId, id, data, isPopulated)
-
-
-
- function extractFormData(formDataArray) {
- return {
- name: formDataArray[0].value,
- sendFrom: formDataArray[1].value,
- subject: formDataArray[2].value
- };
- }
-
- function buildTemplatePayload(data, editor) {
- return JSON.stringify({
- name: data.name,
- description: "",
- subject: data.subject,
- bodyText: editor.getContent({ format: 'text' }),
- bodyHTML: editor.getContent(),
- sendFrom: data.sendFrom
- });
- }
-
- function handleSuccess(message) {
- abp.notify.success(message);
- $("#cardContainer").empty();
- loadCardsFromService();
- return true;
- }
-
- function handleError(message) {
- abp.notify.error(message);
- return false;
- }
-
- function onSaveTemplateSuccess() {
- handleSuccess('Template saved successfully.');
- }
+ function initializeEditor(editorId, id, data, isPopulated, dropdownItems) {
+ if (tinymce.get(editorId)) {
+ tinymce.get(editorId).remove();
+ }
- function onSaveTemplateError() {
- handleError('Failed to save template.');
+ tinymce.init({
+ license_key: 'gpl',
+ selector: `#${editorId}`,
+ plugins: 'lists link image preview code',
+ toolbar: 'undo redo | styles | bold italic | alignleft aligncenter alignright alignjustify | bullist numlist | link image | code preview | variablesDropdownButton',
+ statusbar: false,
+ promotion: false,
+ content_css: false,
+ skin: false,
+ setup: function (editor) {
+ setupEditor(editor, id, editorId, data, isPopulated, dropdownItems);
}
-
- function saveTemplate(payload) {
- $.ajax({
- url: `/api/app/template`,
- method: 'POST',
- contentType: 'application/json',
- data: payload,
- success: onSaveTemplateSuccess,
- error: onSaveTemplateError
- });
+ });
+ }
+
+ function createMenuItems(dropdownItems, editor) {
+ return dropdownItems.map(item => ({
+ type: 'menuitem',
+ text: item.text,
+ onAction: () => {
+ editor.insertContent(`{{${item.value}}}`);
}
+ }));
+ }
+
+ function fetchVariablesMenuItems(dropdownItems, editor) {
+ return function (callback) {
+ const items = createMenuItems(dropdownItems, editor);
+ callback(items);
+ };
+ }
+
+ function setupEditor(editor, id, editorId, data, isPopulated, dropdownItems) {
+ editor.ui.registry.addMenuButton('variablesDropdownButton', {
+ text: 'VARIABLES',
+ fetch: fetchVariablesMenuItems(dropdownItems, editor)
+ });
- function updateTemplate(id, payload) {
- $.ajax({
- url: `/api/app/template/${id}/template`,
- method: 'PUT',
- contentType: 'application/json',
- data: payload,
- success: onSaveTemplateSuccess,
- error: onSaveTemplateError
-
- });
+ editor.on('init', function () {
+ editor.mode.set(isPopulated ? 'readonly' : 'design');
+ if (data?.bodyHTML) {
+ editor.setContent(data.bodyHTML);
}
+ editorInstances[id] = editor;
+ });
+ }
- $(`#${formId} input[name="templateName"]`).on('input', function () {
- const templateInput = $(this);
- const newTitle = templateInput.val().trim() || 'Untitled Template';
- $(`#${wrapperId} .template-title`).text(newTitle);
-
- // Check if name is unique
- checkTemplateNameUnique(newTitle, id, function (isUnique) {
- if (!isUnique) {
- templateInput.addClass("is-invalid");
- if (!$(`#${formId} .template-name-feedback`).length) {
- templateInput.after(`
Template name must be unique.
`);
- }
- $(`#${formId} .saveBtn`).prop("disabled", true);
- } else {
- templateInput.removeClass("is-invalid");
- $(`#${formId} .template-name-feedback`).remove();
- $(`#${formId} .saveBtn`).prop("disabled", false);
- }
- });
- });
-
- $(`#${formId}`).on("submit", function (e) {
- e.preventDefault();
-
- const formDataArray = $(this).serializeArray();
- const formData = extractFormData(formDataArray);
- const editor = editorInstances[id];
- const payload = buildTemplatePayload(formData, editor);
-
- if (id.includes("temp")) {
- saveTemplate(payload);
- } else {
- updateTemplate(id, payload);
- }
- });
-
- $(`#${cardId}`).on('show.bs.collapse', function () {
- $(`#${wrapperId} .btn[data-bs-target="#${cardId}"] i`)
- .removeClass('fa-chevron-down')
- .addClass('fa-chevron-up');
- });
-
- $(`#${cardId}`).on('hide.bs.collapse', function () {
- $(`#${wrapperId} .btn[data-bs-target="#${cardId}"] i`)
- .removeClass('fa-chevron-up')
- .addClass('fa-chevron-down');
- });
-
- $(`#${wrapperId}`).on("click", ".editBtn", function () {
- const currentEditor = editorInstances[id];
- currentEditor.destroy();
-
- initTinyMCE(editorId, id, data, false)
-
- const card = $(`#${wrapperId}`);
- card.find(".form-input").prop('disabled', false);
- card.find(".saveBtn").prop('disabled', false);
- card.find(".discardBtn").removeClass("d-none");
- $(this).addClass("d-none");
- });
-
- $(`#${wrapperId}`).on("click", ".discardBtn", function () {
- const form = $(`#${formId}`)[0];
- form.reset();
- const currentEditor = editorInstances[id];
- currentEditor.destroy();
-
- initTinyMCE(editorId, id, data, true)
-
- $(`#${wrapperId} .form-input`).prop('disabled', true);
- $(`#${wrapperId} .saveBtn`).prop('disabled', true);
- $(`#${wrapperId} .discardBtn`).addClass("d-none");
- $(`#${wrapperId} .editBtn`).removeClass("d-none");
+ function setupCardEventHandlers(cardData) {
+ const { id, formId, cardId, wrapperId, editorId, data, isPopulated, dropdownItems } = cardData;
+
+ setupTemplateNameValidation(formId, wrapperId, id);
+ setupFormSubmission(formId, id);
+ setupCollapseHandlers(cardId, wrapperId);
+ setupEditDiscardHandlers(wrapperId, formId, editorId, id, data, dropdownItems);
+ setupDeleteHandler(wrapperId, id, isPopulated);
+ }
+
+ function setupTemplateNameValidation(formId, wrapperId, id) {
+ const debouncedValidation = debounce(function (templateInput, newTitle) {
+ checkTemplateNameUnique(newTitle, id, function (isUnique) {
+ toggleTemplateNameValidation(templateInput, formId, isUnique);
});
+ }, 250);
- $(`#${formId} input[name="templateName"]`).on('input', function () {
- const newTitle = $(this).val().trim() || 'Untitled Template';
- $(`#${wrapperId} .template-title`).text(newTitle);
- });
+ $(`#${formId} input[name="templateName"]`).on('input', function () {
+ const templateInput = $(this);
+ const newTitle = templateInput.val().trim() || 'Untitled Template';
+ $(`#${wrapperId} .template-title`).text(newTitle);
- $(`#${wrapperId}`).on("click", ".deleteCardBtn", function () {
- if (isPopulated) {
- showDeleteConfirmation(id, wrapperId);
- } else {
- $(`#${wrapperId}`).remove();
- }
- });
+ debouncedValidation(templateInput, newTitle);
+ });
+ }
- function showDeleteConfirmation(id, wrapperId) {
- const swalOptions = {
- title: "Delete Template",
- text: "Are you sure you want to delete this template?",
- showCancelButton: true,
- confirmButtonText: "Confirm",
- customClass: {
- confirmButton: 'btn btn-primary',
- cancelButton: 'btn btn-secondary'
- }
- };
-
- Swal.fire(swalOptions).then(handleResult.bind(null, id, wrapperId));
- }
- function handleResult(id, wrapperId, result) {
- handleDeleteConfirmation(result, id, wrapperId);
+ function toggleTemplateNameValidation(templateInput, formId, isUnique) {
+ if (!isUnique) {
+ templateInput.addClass("is-invalid");
+ if (!$(`#${formId} .template-name-feedback`).length) {
+ templateInput.after(`
Template name must be unique.
`);
}
-
- function handleDeleteConfirmation(result, id, wrapperId) {
- if (!result.isConfirmed) return;
- deleteTemplate(id, wrapperId);
+ $(`#${formId} .saveBtn`).prop("disabled", true);
+ } else {
+ templateInput.removeClass("is-invalid");
+ $(`#${formId} .template-name-feedback`).remove();
+ $(`#${formId} .saveBtn`).prop("disabled", false);
+ }
+ }
+
+ function setupFormSubmission(formId, id) {
+ $(`#${formId}`).on("submit", function (e) {
+ e.preventDefault();
+
+ const formDataArray = $(this).serializeArray();
+ const formData = extractFormData(formDataArray);
+ const editor = editorInstances[id];
+ const payload = buildTemplatePayload(formData, editor);
+
+ if (id.includes("temp")) {
+ saveTemplate(payload);
+ } else {
+ updateTemplate(id, payload);
}
- function handleDeleteSuccess() {
- $(`#${wrapperId}`).remove();
- abp.notify.success('Template deleted successfully.');
+ });
+ }
- }
+ function setupCollapseHandlers(cardId, wrapperId) {
+ $(`#${cardId}`).on('show.bs.collapse', function () {
+ $(`#${wrapperId} .btn[data-bs-target="#${cardId}"] i`)
+ .removeClass('fa-chevron-down')
+ .addClass('fa-chevron-up');
+ });
- function handleDeleteError() {
- abp.notify.error('Error deleting the template.');
- }
+ $(`#${cardId}`).on('hide.bs.collapse', function () {
+ $(`#${wrapperId} .btn[data-bs-target="#${cardId}"] i`)
+ .removeClass('fa-chevron-up')
+ .addClass('fa-chevron-down');
+ });
+ }
- function deleteTemplate(id, wrapperId) {
- $.ajax({
- url: `/api/app/template/${id}/template`,
- type: 'DELETE',
- success: handleDeleteSuccess,
- error: handleDeleteError
- });
- }
- function checkTemplateNameUnique(name, currentId, callback) {
- $.ajax({
- url: `/api/app/template/template-by-name?name=${encodeURIComponent(name)}`,
- type: 'GET',
- success: function (response) {
- // Assume response: { isUnique: true/false }
- // If editing an existing template, allow current name
- const isSameAsCurrent = !currentId.includes('temp') && name === $(`#${wrapperId}`).data('original-name');
- let isExist = false;
- if (response?.id) {
- isExist = true;
- }
-
- callback(!isExist || isSameAsCurrent);
- },
- error: function () {
- callback(false); // Assume not unique if error
- }
- });
- }
+ function setupEditDiscardHandlers(wrapperId, formId, editorId, id, data, dropdownItems) {
+ $(`#${wrapperId}`).on("click", ".editBtn", function () {
+ handleEditClick(wrapperId, editorId, id, data, dropdownItems);
+ });
- function getTemplateVariables() {
- $.ajax({
- url: `/api/app/template/template-variables`,
- type: 'GET',
- success: function (response) {
- $.map(response, function (item) {
- dropdownItems.push( {
- text: item.name,
- value: item.token
- });
- });
- },
- error: function () {
-
- }
- });
+ $(`#${wrapperId}`).on("click", ".discardBtn", function () {
+ handleDiscardClick(wrapperId, formId, editorId, id, data, dropdownItems);
+ });
+ }
+
+ function handleEditClick(wrapperId, editorId, id, data, dropdownItems) {
+ const currentEditor = editorInstances[id];
+ currentEditor.destroy();
+
+ initializeEditor(editorId, id, data, false, dropdownItems);
+
+ const card = $(`#${wrapperId}`);
+ card.find(".form-input").prop('disabled', false);
+ card.find(".saveBtn").prop('disabled', false);
+ card.find(".discardBtn").removeClass("d-none");
+ card.find(".editBtn").addClass("d-none");
+ }
+
+ function handleDiscardClick(wrapperId, formId, editorId, id, data, dropdownItems) {
+ const form = $(`#${formId}`)[0];
+ form.reset();
+ const currentEditor = editorInstances[id];
+ currentEditor.destroy();
+
+ initializeEditor(editorId, id, data, true, dropdownItems);
+
+ $(`#${wrapperId} .form-input`).prop('disabled', true);
+ $(`#${wrapperId} .saveBtn`).prop('disabled', true);
+ $(`#${wrapperId} .discardBtn`).addClass("d-none");
+ $(`#${wrapperId} .editBtn`).removeClass("d-none");
+ }
+
+ function setupDeleteHandler(wrapperId, id, isPopulated) {
+ $(`#${wrapperId}`).on("click", ".deleteCardBtn", function () {
+ if (isPopulated) {
+ showDeleteConfirmation(id, wrapperId);
+ } else {
+ $(`#${wrapperId}`).remove();
}
-
-
- }
-
- function loadCardsFromService() {
- $.ajax({
- url: `/api/app/template/templates-by-tenent`,
- type: 'GET',
- success: handleLoadCardsSuccess,
- error: handleLoadCardsError
- });
- }
-
- function handleLoadCardsSuccess(response) {
- editorInstances = {};
- response.forEach(item => createCard(item));
- }
-
- function handleLoadCardsError() {
- abp.notify.error('Unable to load the templates.');
- }
- function generateTempId() {
- const array = new Uint32Array(1);
- window.crypto.getRandomValues(array);
- return `temp-${array[0].toString(36)}`;
- }
-
- $("#CreateNewTemplate").on("click", function () {
- createCard();
});
-
-
-
+ }
+
+ function createCard(data = null) {
+ const isPopulated = data !== null;
+ const id = data?.id?.toString() || generateTempId();
+ const cardId = `collapseDetails-${id}`;
+ const formId = `form-${id}`;
+ const wrapperId = `cardWrapper-${id}`;
+ const editorId = `editor-${id}`;
+ const type = data?.type || 'Automatic';
+ const lastEdited = getCardLastEditedDate(data);
+ const dropdownItems = [];
+ getTemplateVariables(dropdownItems);
+
+ const cardConfig = {
+ data: data,
+ elementIds: {
+ cardId: cardId,
+ formId: formId,
+ wrapperId: wrapperId,
+ editorId: editorId
+ },
+ displayInfo: {
+ type: type,
+ lastEdited: lastEdited
+ },
+ isPopulated: isPopulated
+ };
+
+ const cardHtml = generateCardHtml(cardConfig);
+
+ $("#cardContainer").append(cardHtml);
+ $(`#${wrapperId}`).data('original-name', data?.name || '');
+
+ initializeEditor(editorId, id, data, isPopulated, dropdownItems);
+
+ const cardData = {
+ id, formId, cardId, wrapperId, editorId, data, isPopulated, dropdownItems
+ };
+ setupCardEventHandlers(cardData);
+ }
+
+ function loadCardsFromService() {
+ $.ajax({
+ url: `/api/app/template/templates-by-tenent`,
+ type: 'GET',
+ success: handleLoadCardsSuccess,
+ error: handleLoadCardsError
+ });
+ }
+
+ function handleLoadCardsSuccess(response) {
+ editorInstances = {};
+ response.forEach(item => createCard(item));
+ }
+
+ function handleLoadCardsError() {
+ abp.notify.error('Unable to load the templates.');
+ }
+ function generateTempId() {
+ const array = new Uint32Array(1);
+ window.crypto.getRandomValues(array);
+ return `temp-${array[0].toString(36)}`;
+ }
+
+ $("#CreateNewTemplate").on("click", function () {
+ createCard();
});
-})(jQuery);
\ No newline at end of file
+});
\ No newline at end of file
diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingPageContributor.cs b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingPageContributor.cs
index ae976271e..37a9622fc 100644
--- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingPageContributor.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingPageContributor.cs
@@ -1,5 +1,4 @@
namespace Unity.Notifications.Web.Views.Settings;
-
using System.Threading.Tasks;
using Unity.Notifications.Permissions;
using Unity.Notifications.Web.Views.Settings.NotificationsSettingGroup;
@@ -8,15 +7,12 @@
public class NotificationsSettingPageContributor : SettingPageContributorBase
{
- public NotificationsSettingPageContributor()
+ public override Task ConfigureAsync(SettingPageCreationContext context)
{
RequiredFeatures(SettingManagementFeatures.Enable);
RequiredTenantSideFeatures("Unity.Notifications");
RequiredPermissions(NotificationsPermissions.Settings);
- }
- public override Task ConfigureAsync(SettingPageCreationContext context)
- {
context.Groups.Add(
new SettingPageGroup(
"GrantManager.Notifications",
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/IPaymentRequestAppService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/IPaymentRequestAppService.cs
index c3e72c776..be35d094c 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/IPaymentRequestAppService.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/IPaymentRequestAppService.cs
@@ -20,5 +20,6 @@ public interface IPaymentRequestAppService : IApplicationService
Task
GetDefaultAccountCodingId();
Task GetUserPaymentThresholdAsync();
Task ManuallyAddPaymentRequestsToReconciliationQueue(List paymentRequestIds);
+ Task> GetPaymentPendingListByCorrelationIdAsync(Guid applicationId);
}
}
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/PaymentRequestDto.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/PaymentRequestDto.cs
index e782f3f36..e00b7be5a 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/PaymentRequestDto.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/PaymentRequestDto.cs
@@ -41,6 +41,11 @@ public class PaymentRequestDto : AuditedEntityDto
public Collection PaymentTags { get; set; }
public Collection ExpenseApprovals { get; set; }
+ // FSB Notification Tracking
+ public Guid? FsbNotificationEmailLogId { get; set; }
+ public DateTime? FsbNotificationSentDate { get; set; }
+ public string? FsbApNotified { get; set; }
+
public static explicit operator PaymentRequestDto(CreatePaymentRequestDto v)
{
throw new NotImplementedException();
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/Suppliers/SupplierDto.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/Suppliers/SupplierDto.cs
index 7354be499..e4291ba6c 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/Suppliers/SupplierDto.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/Suppliers/SupplierDto.cs
@@ -19,12 +19,12 @@ public class SupplierDto : ExtensibleFullAuditedEntityDto
public DateTime? LastUpdatedInCAS { get; set; }
/* Address */
- public string? MailingAddress { get; private set; }
- public string? City { get; private set; }
- public string? Province { get; private set; }
- public string? PostalCode { get; private set; }
+ public string? MailingAddress { get; set; }
+ public string? City { get; set; }
+ public string? Province { get; set; }
+ public string? PostalCode { get; set; }
- public Collection Sites { get; private set; }
+ public Collection Sites { get; set; }
}
#pragma warning restore CS8618
}
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 4ea6b8752..91edb85a6 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
@@ -63,6 +63,12 @@ public virtual Site Site
public virtual Guid? AccountCodingId { get; private set; }
public virtual AccountCoding? AccountCoding { get; set; } = null;
public virtual string? Note { get; private set; } = null;
+
+ // FSB Notification Tracking
+ public virtual Guid? FsbNotificationEmailLogId { get; private set; }
+ public virtual DateTime? FsbNotificationSentDate { get; private set; }
+ public virtual string? FsbApNotified { get; private set; }
+
protected PaymentRequest()
{
ExpenseApprovals = [];
@@ -176,6 +182,22 @@ public PaymentRequest SetCasResponse(string casResponse)
return this;
}
+ public PaymentRequest SetFsbNotificationEmailLog(Guid emailLogId, DateTime sentDate)
+ {
+ FsbNotificationEmailLogId = emailLogId;
+ FsbNotificationSentDate = sentDate;
+ FsbApNotified = "Yes";
+ return this;
+ }
+
+ public PaymentRequest ClearFsbNotificationEmailLog()
+ {
+ FsbNotificationEmailLogId = null;
+ FsbNotificationSentDate = null;
+ FsbApNotified = null;
+ return this;
+ }
+
public PaymentRequest ValidatePaymentRequest()
{
if (Amount <= 0)
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IInvoiceManager.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IInvoiceManager.cs
new file mode 100644
index 000000000..71760dec7
--- /dev/null
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IInvoiceManager.cs
@@ -0,0 +1,24 @@
+using System;
+using System.Threading.Tasks;
+using Unity.Payments.Domain.PaymentRequests;
+using Unity.Payments.Domain.Suppliers;
+using Unity.Payments.Domain.AccountCodings;
+using Unity.Payments.Integrations.Http;
+
+namespace Unity.Payments.Domain.Services
+{
+ public interface IInvoiceManager
+ {
+ Task GetSiteByPaymentRequestAsync(PaymentRequest paymentRequest);
+ Task GetPaymentRequestDataAsync(string invoiceNumber);
+ Task UpdatePaymentRequestWithInvoiceAsync(Guid paymentRequestId, InvoiceResponse invoiceResponse);
+
+ }
+
+ public class PaymentRequestData
+ {
+ public PaymentRequest PaymentRequest { get; set; } = null!;
+ public AccountCoding AccountCoding { get; set; } = null!;
+ public string AccountDistributionCode { get; set; } = null!;
+ }
+}
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentRequestConfigurationManager.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentRequestConfigurationManager.cs
new file mode 100644
index 000000000..8c39cb057
--- /dev/null
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentRequestConfigurationManager.cs
@@ -0,0 +1,26 @@
+using System;
+using System.Threading.Tasks;
+using Unity.Payments.Domain.PaymentConfigurations;
+
+namespace Unity.Payments.Domain.Services
+{
+ public interface IPaymentRequestConfigurationManager
+ {
+ // Configuration & Lookup
+ Task GetDefaultAccountCodingIdAsync();
+ Task GetPaymentConfigurationAsync();
+ Task GetNextBatchInfoAsync();
+ Task GetMaxBatchNumberAsync();
+
+ // Threshold & Approval Logic
+ Task GetPaymentRequestThresholdByApplicationIdAsync(Guid applicationId, decimal? userPaymentThreshold);
+ Task GetUserPaymentThresholdAsync(Guid? userId);
+
+ // Utility Methods for Batch/Sequence Generation
+ Task GetNextSequenceNumberAsync(int currentYear);
+ string GenerateReferenceNumberPrefix(string paymentIdPrefix);
+ string GenerateSequenceNumber(int sequenceNumber, int index);
+ string GenerateReferenceNumber(string referenceNumber, string sequencePart);
+ string GenerateInvoiceNumber(string referenceNumber, string invoiceNumber, string sequencePart);
+ }
+}
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentRequestQueryManager.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentRequestQueryManager.cs
new file mode 100644
index 000000000..222dc9f83
--- /dev/null
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/IPaymentRequestQueryManager.cs
@@ -0,0 +1,39 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Unity.Payments.Domain.PaymentRequests;
+using Unity.Payments.PaymentRequests;
+
+namespace Unity.Payments.Domain.Services
+{
+ public interface IPaymentRequestQueryManager
+ {
+ // Payment Request Queries
+ Task GetPaymentRequestCountBySiteIdAsync(Guid siteId);
+ Task GetPaymentRequestCountAsync();
+ Task GetPaymentRequestByIdAsync(Guid paymentRequestId);
+ Task> GetPaymentRequestsByIdsAsync(List paymentRequestIds, bool includeDetails = false);
+ Task> GetPagedPaymentRequestsWithIncludesAsync(int skipCount, int maxResultCount, string sorting);
+ Task> GetListByApplicationIdAsync(Guid applicationId);
+ Task> GetListByApplicationIdsAsync(List applicationIds);
+ Task> GetListByPaymentIdsAsync(List paymentIds);
+ Task GetTotalPaymentRequestAmountByCorrelationIdAsync(Guid correlationId);
+
+ // Payment Request Operations
+ Task InsertPaymentRequestAsync(PaymentRequest paymentRequest);
+
+ // DTO Creation & Mapping
+ Task CreatePaymentRequestDtoAsync(Guid paymentRequestId);
+ Task> MapToDtoAndLoadDetailsAsync(List paymentsList);
+ Task GetAccountDistributionCodeAsync(AccountCodingDto? accountCoding);
+
+ // Queue Operations
+ Task ManuallyAddPaymentRequestsToReconciliationQueueAsync(List paymentRequestIds);
+
+ // Helper Method
+ void ApplyErrorSummary(List mappedPayments);
+
+ // Pending Payments
+ Task> GetPaymentPendingListByCorrelationIdAsync(Guid applicationId);
+ }
+}
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/InvoiceManager.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/InvoiceManager.cs
new file mode 100644
index 000000000..565b400a5
--- /dev/null
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/InvoiceManager.cs
@@ -0,0 +1,154 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using System;
+using System.Threading.Tasks;
+using Unity.Payments.Codes;
+using Unity.Payments.Domain.AccountCodings;
+using Unity.Payments.Domain.PaymentRequests;
+using Unity.Payments.Domain.Suppliers;
+using Unity.Payments.Integrations.Http;
+using Unity.Payments.PaymentConfigurations;
+using Volo.Abp;
+using Volo.Abp.Data;
+using Volo.Abp.Domain.Services;
+using Volo.Abp.Uow;
+
+namespace Unity.Payments.Domain.Services
+{
+ public class InvoiceManager(
+ IAccountCodingRepository accountCodingRepository,
+ PaymentConfigurationAppService paymentConfigurationAppService,
+ IPaymentRequestRepository paymentRequestRepository,
+ ISupplierRepository supplierRepository,
+ ISiteRepository siteRepository,
+ IUnitOfWorkManager unitOfWorkManager) : DomainService, IInvoiceManager
+ {
+ public async Task GetSiteByPaymentRequestAsync(PaymentRequest paymentRequest)
+ {
+ Site? site = await siteRepository.GetAsync(paymentRequest.SiteId, true);
+ if (site?.SupplierId != null)
+ {
+ Supplier supplier = await supplierRepository.GetAsync(site.SupplierId);
+ site.Supplier = supplier;
+ }
+ return site;
+ }
+
+ public async Task GetPaymentRequestDataAsync(string invoiceNumber)
+ {
+ var paymentRequest = await paymentRequestRepository.GetPaymentRequestByInvoiceNumber(invoiceNumber)
+ ?? throw new UserFriendlyException("CreateInvoiceByPaymentRequestAsync: Payment Request not found");
+
+ if (!paymentRequest.AccountCodingId.HasValue)
+ throw new UserFriendlyException("CreateInvoiceByPaymentRequestAsync: Account Coding - Payment Request - not found");
+
+ AccountCoding accountCoding = await accountCodingRepository.GetAsync(paymentRequest.AccountCodingId.Value);
+ string accountDistributionCode = await paymentConfigurationAppService.GetAccountDistributionCode(accountCoding);
+
+ return new PaymentRequestData
+ {
+ PaymentRequest = paymentRequest,
+ AccountCoding = accountCoding,
+ AccountDistributionCode = accountDistributionCode
+ };
+ }
+
+ public async Task UpdatePaymentRequestWithInvoiceAsync(Guid paymentRequestId, InvoiceResponse invoiceResponse)
+ {
+ const int maxRetries = 3;
+
+ for (int attempt = 1; attempt <= maxRetries; attempt++)
+ {
+ try
+ {
+ // Each attempt must have a fresh UoW
+ using (var uow = unitOfWorkManager.Begin())
+ {
+ // Load with tracking
+ var paymentRequest = await paymentRequestRepository.GetAsync(paymentRequestId);
+
+ if (paymentRequest == null)
+ {
+ Logger.LogWarning("PaymentRequest {Id} not found. Skipping update.", paymentRequestId);
+ return;
+ }
+
+ // Idempotency: do not re-process
+ if (paymentRequest.InvoiceStatus == CasPaymentRequestStatus.SentToCas)
+ {
+ Logger.LogInformation(
+ "PaymentRequest {Id} already invoiced. Skipping update.",
+ paymentRequestId
+ );
+ return;
+ }
+
+ // Apply CAS response info
+ paymentRequest.SetCasHttpStatusCode((int)invoiceResponse.CASHttpStatusCode);
+ paymentRequest.SetCasResponse(invoiceResponse.CASReturnedMessages);
+
+ // Set status
+ paymentRequest.SetInvoiceStatus(
+ invoiceResponse.IsSuccess()
+ ? CasPaymentRequestStatus.SentToCas
+ : CasPaymentRequestStatus.ErrorFromCas
+ );
+
+ await paymentRequestRepository.UpdateAsync(paymentRequest, autoSave: false);
+
+ // Commit this attempt
+ await uow.CompleteAsync();
+
+ Logger.LogInformation(
+ "PaymentRequest {Id} updated successfully on attempt {Attempt}.",
+ paymentRequestId,
+ attempt
+ );
+ return; // success
+ }
+ }
+ catch (Exception ex) when (
+ ex is AbpDbConcurrencyException ||
+ ex is DbUpdateConcurrencyException
+ )
+ {
+ Logger.LogWarning(
+ ex,
+ "Concurrency conflict when updating PaymentRequest {Id}, attempt {Attempt}",
+ paymentRequestId,
+ attempt
+ );
+
+ if (attempt == maxRetries)
+ {
+ Logger.LogError(
+ ex,
+ "Max retries reached for PaymentRequest {Id}. Manual intervention may be required.",
+ paymentRequestId
+ );
+
+ throw new UserFriendlyException(
+ $"Failed to update payment request {paymentRequestId} after {maxRetries} attempts due to concurrency conflicts."
+ );
+ }
+
+ // Brief pause before retrying to reduce immediate collision
+ await Task.Delay(75);
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError(
+ ex,
+ "Unexpected exception updating PaymentRequest {Id} on attempt {Attempt}",
+ paymentRequestId,
+ attempt
+ );
+
+ throw new UserFriendlyException(
+ $"Failed to update payment request {paymentRequestId}: {ex.Message}"
+ );
+ }
+ }
+ }
+ }
+}
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestConfigurationManager.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestConfigurationManager.cs
new file mode 100644
index 000000000..26a3bfa8a
--- /dev/null
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestConfigurationManager.cs
@@ -0,0 +1,157 @@
+using Microsoft.EntityFrameworkCore;
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using Unity.GrantManager.Applications;
+using Unity.Payments.Domain.PaymentConfigurations;
+using Unity.Payments.Domain.PaymentRequests;
+using Unity.Payments.Domain.PaymentThresholds;
+using Volo.Abp;
+using Volo.Abp.Domain.Repositories;
+using Volo.Abp.Domain.Services;
+
+namespace Unity.Payments.Domain.Services
+{
+ public class PaymentRequestConfigurationManager(
+ IApplicationRepository applicationRepository,
+ IApplicationFormRepository applicationFormRepository,
+ IPaymentConfigurationRepository paymentConfigurationRepository,
+ IPaymentThresholdRepository paymentThresholdRepository,
+ IPaymentRequestRepository paymentRequestRepository) : DomainService, IPaymentRequestConfigurationManager
+ {
+ public async Task GetDefaultAccountCodingIdAsync()
+ {
+ Guid? accountCodingId = null;
+ // If no account coding is found look up the payment configuration
+ PaymentConfiguration? paymentConfiguration = await GetPaymentConfigurationAsync();
+ if (paymentConfiguration != null && paymentConfiguration.DefaultAccountCodingId.HasValue)
+ {
+ accountCodingId = paymentConfiguration.DefaultAccountCodingId;
+ }
+ return accountCodingId;
+ }
+
+ public async Task GetNextBatchInfoAsync()
+ {
+ var paymentConfig = await GetPaymentConfigurationAsync();
+ var paymentIdPrefix = string.Empty;
+
+ if (paymentConfig != null && !paymentConfig.PaymentIdPrefix.IsNullOrEmpty())
+ {
+ paymentIdPrefix = paymentConfig.PaymentIdPrefix;
+ }
+
+ var batchNumber = await GetMaxBatchNumberAsync();
+ var batchName = $"{paymentIdPrefix}_UNITY_BATCH_{batchNumber}";
+
+ return batchName;
+ }
+
+ public string GenerateInvoiceNumber(string referenceNumber, string invoiceNumber, string sequencePart)
+ {
+ return $"{referenceNumber}-{invoiceNumber}-{sequencePart}";
+ }
+
+ public string GenerateReferenceNumber(string referenceNumber, string sequencePart)
+ {
+ return $"{referenceNumber}-{sequencePart}";
+ }
+
+ public string GenerateSequenceNumber(int sequenceNumber, int index)
+ {
+ sequenceNumber += index;
+ return sequenceNumber.ToString("D4");
+ }
+
+ public string GenerateReferenceNumberPrefix(string paymentIdPrefix)
+ {
+ var currentYear = DateTime.UtcNow.Year;
+ var yearPart = currentYear.ToString();
+ return $"{paymentIdPrefix}-{yearPart}";
+ }
+
+ public async Task GetMaxBatchNumberAsync()
+ {
+ var paymentRequestList = await paymentRequestRepository.GetListAsync();
+ decimal batchNumber = 1; // Lookup max plus 1
+ if (paymentRequestList != null && paymentRequestList.Count > 0)
+ {
+ var maxBatchNumber = paymentRequestList.Max(s => s.BatchNumber);
+
+ if (maxBatchNumber > 0)
+ {
+ batchNumber = maxBatchNumber + 1;
+ }
+ }
+
+ return batchNumber;
+ }
+
+ public async Task GetPaymentRequestThresholdByApplicationIdAsync(Guid applicationId, decimal? userPaymentThreshold)
+ {
+ var application = await (await applicationRepository.GetQueryableAsync())
+ .Include(a => a.ApplicationForm)
+ .FirstOrDefaultAsync(a => a.Id == applicationId) ?? throw new BusinessException($"Application with Id {applicationId} not found.");
+ var appForm = application.ApplicationForm ??
+ (application.ApplicationFormId != Guid.Empty
+ ? await applicationFormRepository.GetAsync(application.ApplicationFormId)
+ : null);
+
+ var formThreshold = appForm?.PaymentApprovalThreshold;
+
+ if (formThreshold.HasValue && userPaymentThreshold.HasValue)
+ {
+ return Math.Min(formThreshold.Value, userPaymentThreshold.Value);
+ }
+
+ return formThreshold ?? userPaymentThreshold ?? 0m;
+ }
+
+ public async Task GetUserPaymentThresholdAsync(Guid? userId)
+ {
+ var userThreshold = await paymentThresholdRepository.FirstOrDefaultAsync(x => x.UserId == userId);
+ return userThreshold?.Threshold;
+ }
+
+ public async Task GetPaymentConfigurationAsync()
+ {
+ var paymentConfigs = await paymentConfigurationRepository.GetListAsync();
+
+ if (paymentConfigs.Count > 0)
+ {
+ var paymentConfig = paymentConfigs[0];
+ return paymentConfig;
+ }
+
+ return null;
+ }
+
+ public async Task GetNextSequenceNumberAsync(int currentYear)
+ {
+ // Retrieve all payment requests
+ var payments = await paymentRequestRepository.GetListAsync();
+
+ // Filter payments for the current year
+ var filteredPayments = payments
+ .Where(p => p.CreationTime.Year == currentYear)
+ .OrderByDescending(p => p.CreationTime)
+ .ToList();
+
+ // Use the first payment in the sorted list (most recent) if available
+ if (filteredPayments.Count > 0)
+ {
+ var latestPayment = filteredPayments[0]; // Access the most recent payment directly
+ var referenceParts = latestPayment.ReferenceNumber.Split('-');
+
+ // Extract the sequence number from the reference number safely
+ if (referenceParts.Length > 0 && int.TryParse(referenceParts[^1], out int latestSequenceNumber))
+ {
+ return latestSequenceNumber + 1;
+ }
+ }
+
+ // If no payments exist or parsing fails, return the initial sequence number
+ return 1;
+ }
+ }
+}
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs
new file mode 100644
index 000000000..f5fe8e972
--- /dev/null
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Services/PaymentRequestQueryManager.cs
@@ -0,0 +1,222 @@
+using Microsoft.EntityFrameworkCore;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Unity.Payments.Domain.PaymentRequests;
+using Unity.Payments.Domain.Suppliers;
+using Unity.Payments.Enums;
+using Unity.Payments.PaymentRequests;
+using Unity.Payments.Suppliers;
+using Volo.Abp.Domain.Services;
+using Volo.Abp.ObjectMapping;
+using Volo.Abp.Users;
+
+namespace Unity.Payments.Domain.Services
+{
+ public class PaymentRequestQueryManager(
+ IPaymentRequestRepository paymentRequestRepository,
+ ISiteRepository siteRepository,
+ IExternalUserLookupServiceProvider externalUserLookupServiceProvider,
+ CasPaymentRequestCoordinator casPaymentRequestCoordinator,
+ IObjectMapper objectMapper) : DomainService, IPaymentRequestQueryManager
+ {
+ public Task GetPaymentRequestCountBySiteIdAsync(Guid siteId)
+ {
+ return paymentRequestRepository.GetPaymentRequestCountBySiteId(siteId);
+ }
+
+ public async Task GetPaymentRequestCountAsync()
+ {
+ return await paymentRequestRepository.GetCountAsync();
+ }
+
+ public async Task GetPaymentRequestByIdAsync(Guid paymentRequestId)
+ {
+ return await paymentRequestRepository.GetAsync(paymentRequestId);
+ }
+
+ public async Task> GetPaymentRequestsByIdsAsync(List paymentRequestIds, bool includeDetails = false)
+ {
+ return await paymentRequestRepository.GetListAsync(x => paymentRequestIds.Contains(x.Id), includeDetails: includeDetails);
+ }
+
+ public async Task> GetPagedPaymentRequestsWithIncludesAsync(int skipCount, int maxResultCount, string sorting)
+ {
+ await paymentRequestRepository.GetPagedListAsync(skipCount, maxResultCount, sorting, includeDetails: true);
+
+ var paymentsQueryable = await paymentRequestRepository.GetQueryableAsync();
+#pragma warning disable CS8620 // Argument cannot be used for parameter due to differences in the nullability of reference types.
+ var paymentWithIncludes = await paymentsQueryable
+ .Include(pr => pr.AccountCoding)
+ .Include(pr => pr.PaymentTags)
+ .ThenInclude(pt => pt.Tag)
+ .ToListAsync();
+#pragma warning restore CS8620 // Argument cannot be used for parameter due to differences in the nullability of reference types.
+
+ return paymentWithIncludes;
+ }
+
+ public async Task InsertPaymentRequestAsync(PaymentRequest paymentRequest)
+ {
+ return await paymentRequestRepository.InsertAsync(paymentRequest);
+ }
+
+ public async Task CreatePaymentRequestDtoAsync(Guid paymentRequestId)
+ {
+ var payment = await paymentRequestRepository.GetAsync(paymentRequestId);
+ return new PaymentRequestDto
+ {
+ Id = payment.Id,
+ InvoiceNumber = payment.InvoiceNumber,
+ InvoiceStatus = payment.InvoiceStatus,
+ Amount = payment.Amount,
+ PayeeName = payment.PayeeName,
+ SupplierNumber = payment.SupplierNumber,
+ ContractNumber = payment.ContractNumber,
+ CorrelationId = payment.CorrelationId,
+ CorrelationProvider = payment.CorrelationProvider,
+ Description = payment.Description,
+ CreationTime = payment.CreationTime,
+ Status = payment.Status,
+ ReferenceNumber = payment.ReferenceNumber,
+ SubmissionConfirmationCode = payment.SubmissionConfirmationCode,
+ Note = payment.Note
+ };
+ }
+
+ public async Task> GetListByApplicationIdsAsync(List applicationIds)
+ {
+ var paymentsQueryable = await paymentRequestRepository.GetQueryableAsync();
+ var payments = await paymentsQueryable.Include(pr => pr.Site).ToListAsync();
+ var filteredPayments = payments.Where(pr => applicationIds.Contains(pr.CorrelationId)).ToList();
+
+ return objectMapper.Map, List>(filteredPayments);
+ }
+
+ public async Task> GetListByApplicationIdAsync(Guid applicationId)
+ {
+ var paymentsQueryable = await paymentRequestRepository.GetQueryableAsync();
+ var payments = await paymentsQueryable.Include(pr => pr.Site).ToListAsync();
+ var filteredPayments = payments.Where(e => e.CorrelationId == applicationId).ToList();
+
+ return objectMapper.Map, List>(filteredPayments);
+ }
+
+ public async Task> GetListByPaymentIdsAsync(List paymentIds)
+ {
+ var paymentsQueryable = await paymentRequestRepository.GetQueryableAsync();
+ var payments = await paymentsQueryable
+ .Where(e => paymentIds.Contains(e.Id))
+ .Include(pr => pr.Site)
+ .Include(x => x.ExpenseApprovals)
+ .ToListAsync();
+
+ return objectMapper.Map, List>(payments);
+ }
+
+ public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync(Guid correlationId)
+ {
+ return await paymentRequestRepository.GetTotalPaymentRequestAmountByCorrelationIdAsync(correlationId);
+ }
+
+ public async Task> MapToDtoAndLoadDetailsAsync(List paymentsList)
+ {
+ var paymentDtos = objectMapper.Map, List>(paymentsList);
+
+ // Flatten all DecisionUserIds from ExpenseApprovals across all PaymentRequestDtos
+ List paymentRequesterIds = [.. paymentDtos
+ .Select(payment => payment.CreatorId)
+ .OfType()
+ .Distinct()];
+
+ List expenseApprovalCreatorIds = [.. paymentDtos
+ .SelectMany(payment => payment.ExpenseApprovals)
+ .Where(expenseApproval => expenseApproval.Status != ExpenseApprovalStatus.Requested)
+ .Select(expenseApproval => expenseApproval.DecisionUserId)
+ .OfType()
+ .Distinct()];
+
+ // Call external lookup for each distinct User Id and store in a dictionary.
+ var userDictionary = new Dictionary();
+ var allUserIds = paymentRequesterIds.Concat(expenseApprovalCreatorIds).Distinct();
+ foreach (var userId in allUserIds)
+ {
+ var userInfo = await externalUserLookupServiceProvider.FindByIdAsync(userId);
+ if (userInfo != null)
+ {
+ userDictionary[userId] = objectMapper.Map(userInfo);
+ }
+ }
+
+ // Map UserInfo details to each ExpenseApprovalDto
+ foreach (var paymentRequestDto in paymentDtos)
+ {
+ if (paymentRequestDto.CreatorId.HasValue
+ && userDictionary.TryGetValue(paymentRequestDto.CreatorId.Value, out var paymentRequestUserDto))
+ {
+ paymentRequestDto.CreatorUser = paymentRequestUserDto;
+ }
+
+ if (paymentRequestDto.AccountCoding != null)
+ {
+ paymentRequestDto.AccountCodingDisplay = await GetAccountDistributionCodeAsync(paymentRequestDto.AccountCoding);
+ }
+
+ if (paymentRequestDto.ExpenseApprovals != null)
+ {
+ foreach (var expenseApproval in paymentRequestDto.ExpenseApprovals)
+ {
+ if (expenseApproval.DecisionUserId.HasValue
+ && userDictionary.TryGetValue(expenseApproval.DecisionUserId.Value, out var expenseApprovalUserDto))
+ {
+ expenseApproval.DecisionUser = expenseApprovalUserDto;
+ }
+ }
+ }
+ }
+
+ return paymentDtos;
+ }
+
+ public Task GetAccountDistributionCodeAsync(AccountCodingDto? accountCoding)
+ {
+ return Task.FromResult(AccountCodingFormatter.Format(accountCoding));
+ }
+
+ public void ApplyErrorSummary(List mappedPayments)
+ {
+ mappedPayments.ForEach(mappedPayment =>
+ {
+ if (!string.IsNullOrWhiteSpace(mappedPayment.CasResponse) &&
+ !mappedPayment.CasResponse.Equals("SUCCEEDED", StringComparison.OrdinalIgnoreCase))
+ {
+ mappedPayment.ErrorSummary = mappedPayment.CasResponse;
+ }
+ });
+ }
+
+ public async Task ManuallyAddPaymentRequestsToReconciliationQueueAsync(List paymentRequestIds)
+ {
+ List paymentRequestDtos = [];
+ foreach (var paymentRequestId in paymentRequestIds)
+ {
+ var paymentRequest = await paymentRequestRepository.GetAsync(paymentRequestId);
+ if (paymentRequest != null)
+ {
+ var paymentRequestDto = objectMapper.Map(paymentRequest);
+ Site site = await siteRepository.GetAsync(paymentRequest.SiteId);
+ paymentRequestDto.Site = objectMapper.Map(site);
+ paymentRequestDtos.Add(paymentRequestDto);
+ }
+ }
+ await casPaymentRequestCoordinator.ManuallyAddPaymentRequestsToReconciliationQueue(paymentRequestDtos);
+ }
+
+ public async Task> GetPaymentPendingListByCorrelationIdAsync(Guid applicationId)
+ {
+ var payments = await paymentRequestRepository.GetPaymentPendingListByCorrelationIdAsync(applicationId);
+ return objectMapper.Map, List>(payments);
+ }
+ }
+}
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Suppliers/Supplier.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Suppliers/Supplier.cs
index 503b32526..67cbc347e 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Suppliers/Supplier.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Suppliers/Supplier.cs
@@ -37,22 +37,21 @@ public class Supplier : FullAuditedAggregateRoot, IMultiTenant, ICorrelati
protected Supplier()
{
/* This constructor is for ORMs to be used while getting the entity from the database. */
- Sites = new Collection();
+ Sites = [];
}
public Supplier(Guid id,
string? name,
string? number,
- Guid correlationId,
- string correlationProvider,
+ Correlation correlation,
MailingAddress? mailingAddress = default)
: base(id)
{
Name = name;
Number = number;
- CorrelationId = correlationId;
- CorrelationProvider = correlationProvider;
- Sites = new Collection();
+ CorrelationId = correlation.CorrelationId;
+ CorrelationProvider = correlation.CorrelationProvider;
+ Sites = [];
MailingAddress = mailingAddress?.AddressLine;
City = mailingAddress?.City;
Province = mailingAddress?.Province;
@@ -60,32 +59,26 @@ public Supplier(Guid id,
}
public Supplier(Guid id,
- string? name,
- string? number,
- string? subcategory,
- string? providerId,
- string? businessNumber,
- string? status,
- string? supplierProtected,
- string? standardIndustryClassification,
- DateTime? lastUpdatedInCAS,
- Guid correlationId,
- string correlationProvider,
+ SupplierBasicInfo basicInfo,
+ Correlation correlation,
+ ProviderInfo? providerInfo = default,
+ SupplierStatus? supplierStatus = default,
+ CasMetadata? casMetadata = default,
MailingAddress? mailingAddress = default)
: base(id)
{
- Name = name;
- Number = number;
- Subcategory = subcategory;
- ProviderId = providerId;
- BusinessNumber = businessNumber;
- Status = status;
- SupplierProtected = supplierProtected;
- StandardIndustryClassification = standardIndustryClassification;
- LastUpdatedInCAS = lastUpdatedInCAS;
- CorrelationId = correlationId;
- CorrelationProvider = correlationProvider;
- Sites = new Collection();
+ Name = basicInfo.Name;
+ Number = basicInfo.Number;
+ Subcategory = basicInfo.Subcategory;
+ ProviderId = providerInfo?.ProviderId ?? ProviderId;
+ BusinessNumber = providerInfo?.BusinessNumber ?? BusinessNumber;
+ Status = supplierStatus?.Status ?? Status;
+ SupplierProtected = supplierStatus?.SupplierProtected ?? SupplierProtected;
+ StandardIndustryClassification = supplierStatus?.StandardIndustryClassification ?? StandardIndustryClassification;
+ LastUpdatedInCAS = casMetadata?.LastUpdatedInCAS ?? LastUpdatedInCAS;
+ CorrelationId = correlation.CorrelationId;
+ CorrelationProvider = correlation.CorrelationProvider;
+ Sites = [];
MailingAddress = mailingAddress?.AddressLine;
City = mailingAddress?.City;
Province = mailingAddress?.Province;
@@ -135,5 +128,38 @@ public void SetAddress(string? mailingAddress,
Province = province;
PostalCode = postalCode;
}
+
+ public void UpdateBasicInfo(SupplierBasicInfo basicInfo)
+ {
+ /* Business rules around updating basic supplier information */
+
+ Name = basicInfo.Name;
+ Number = basicInfo.Number;
+ Subcategory = basicInfo.Subcategory;
+ }
+
+ public void UpdateProviderInfo(ProviderInfo providerInfo)
+ {
+ /* Business rules around updating provider information */
+
+ ProviderId = providerInfo.ProviderId;
+ BusinessNumber = providerInfo.BusinessNumber;
+ }
+
+ public void UpdateStatus(SupplierStatus supplierStatus)
+ {
+ /* Business rules around updating supplier status */
+
+ Status = supplierStatus.Status;
+ SupplierProtected = supplierStatus.SupplierProtected;
+ StandardIndustryClassification = supplierStatus.StandardIndustryClassification;
+ }
+
+ public void UpdateCasMetadata(CasMetadata casMetadata)
+ {
+ /* Business rules around updating CAS metadata */
+
+ LastUpdatedInCAS = casMetadata.LastUpdatedInCAS;
+ }
}
}
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Suppliers/ValueObjects/CasMetadata.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Suppliers/ValueObjects/CasMetadata.cs
new file mode 100644
index 000000000..d56329a59
--- /dev/null
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Suppliers/ValueObjects/CasMetadata.cs
@@ -0,0 +1,6 @@
+using System;
+
+namespace Unity.Payments.Domain.Suppliers.ValueObjects
+{
+ public record CasMetadata(DateTime? LastUpdatedInCAS = default);
+}
\ No newline at end of file
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Suppliers/ValueObjects/ProviderInfo.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Suppliers/ValueObjects/ProviderInfo.cs
new file mode 100644
index 000000000..3ceb8aca9
--- /dev/null
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Suppliers/ValueObjects/ProviderInfo.cs
@@ -0,0 +1,4 @@
+namespace Unity.Payments.Domain.Suppliers.ValueObjects
+{
+ public record ProviderInfo(string? ProviderId, string? BusinessNumber = default);
+}
\ No newline at end of file
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Suppliers/ValueObjects/SupplierBasicInfo.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Suppliers/ValueObjects/SupplierBasicInfo.cs
new file mode 100644
index 000000000..a2ac350ee
--- /dev/null
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Suppliers/ValueObjects/SupplierBasicInfo.cs
@@ -0,0 +1,4 @@
+namespace Unity.Payments.Domain.Suppliers.ValueObjects
+{
+ public record SupplierBasicInfo(string? Name, string? Number, string? Subcategory = default);
+}
\ No newline at end of file
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Suppliers/ValueObjects/SupplierStatus.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Suppliers/ValueObjects/SupplierStatus.cs
new file mode 100644
index 000000000..2a9b5bc08
--- /dev/null
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/Suppliers/ValueObjects/SupplierStatus.cs
@@ -0,0 +1,6 @@
+namespace Unity.Payments.Domain.Suppliers.ValueObjects
+{
+ public record SupplierStatus(string? Status,
+ string? SupplierProtected = default,
+ string? StandardIndustryClassification = default);
+}
\ No newline at end of file
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/PaymentsDbContextModelCreatingExtensions.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/PaymentsDbContextModelCreatingExtensions.cs
index 962e3f3fa..6f1a35251 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/PaymentsDbContextModelCreatingExtensions.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/PaymentsDbContextModelCreatingExtensions.cs
@@ -38,9 +38,15 @@ public static void ConfigurePayments(
b.HasOne(e => e.AccountCoding)
.WithMany()
.HasForeignKey(x => x.AccountCodingId)
- .OnDelete(DeleteBehavior.NoAction);
-
+ .OnDelete(DeleteBehavior.NoAction);
+
b.HasIndex(e => e.ReferenceNumber).IsUnique();
+
+ // FSB Notification Tracking
+ b.Property(x => x.FsbNotificationEmailLogId).IsRequired(false);
+ b.Property(x => x.FsbNotificationSentDate).IsRequired(false);
+ b.Property(x => x.FsbApNotified).IsRequired(false).HasMaxLength(10);
+ b.HasIndex(x => x.FsbNotificationEmailLogId);
});
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Handlers/FsbEmailSentEventHandler.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Handlers/FsbEmailSentEventHandler.cs
new file mode 100644
index 000000000..5ab764889
--- /dev/null
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Handlers/FsbEmailSentEventHandler.cs
@@ -0,0 +1,81 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Unity.Notifications.Events;
+using Unity.Payments.Domain.PaymentRequests;
+using Volo.Abp.DependencyInjection;
+using Volo.Abp.EventBus;
+using Volo.Abp.MultiTenancy;
+using Volo.Abp.Uow;
+
+namespace Unity.Payments.Handlers
+{
+ ///
+ /// Handles FSB email sent events to update payment request tracking
+ ///
+ public class FsbEmailSentEventHandler :
+ ILocalEventHandler,
+ ITransientDependency
+ {
+ private readonly IPaymentRequestRepository _paymentRequestRepository;
+ private readonly ICurrentTenant _currentTenant;
+ private readonly IUnitOfWorkManager _unitOfWorkManager;
+ private readonly ILogger _logger;
+
+ public FsbEmailSentEventHandler(
+ IPaymentRequestRepository paymentRequestRepository,
+ ICurrentTenant currentTenant,
+ IUnitOfWorkManager unitOfWorkManager,
+ ILogger logger)
+ {
+ _paymentRequestRepository = paymentRequestRepository;
+ _currentTenant = currentTenant;
+ _unitOfWorkManager = unitOfWorkManager;
+ _logger = logger;
+ }
+
+ public async Task HandleEventAsync(FsbEmailSentEto eventData)
+ {
+ using (_currentTenant.Change(eventData.TenantId))
+ {
+ using var uow = _unitOfWorkManager.Begin(requiresNew: true, isTransactional: true);
+
+ try
+ {
+ foreach (var paymentId in eventData.PaymentRequestIds)
+ {
+ try
+ {
+ var payment = await _paymentRequestRepository.GetAsync(paymentId, includeDetails: false);
+ payment.SetFsbNotificationEmailLog(eventData.EmailLogId, eventData.SentDate);
+ await _paymentRequestRepository.UpdateAsync(payment, autoSave: false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex,
+ "Failed to update FSB notification tracking for payment {PaymentId}",
+ paymentId);
+ // Continue processing other payments
+ }
+ }
+
+ await uow.SaveChangesAsync();
+ await uow.CompleteAsync();
+
+ _logger.LogInformation(
+ "Updated FSB notification tracking for {Count} payments. EmailLogId: {EmailLogId}",
+ eventData.PaymentRequestIds.Count,
+ eventData.EmailLogId);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex,
+ "Failed to update FSB notification tracking for batch.");
+ throw new InvalidOperationException(
+ $"Failed to update FSB notification tracking for batch.",
+ ex);
+ }
+ }
+ }
+ }
+}
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/InvoiceService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/InvoiceService.cs
index e00c99a19..9e7e70623 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/InvoiceService.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/InvoiceService.cs
@@ -6,37 +6,25 @@
using Unity.Payments.Integrations.Http;
using Volo.Abp.Application.Services;
using System.Collections.Generic;
-using Volo.Abp.Data;
-using Microsoft.EntityFrameworkCore;
using Unity.Payments.Enums;
using Unity.Payments.Domain.Suppliers;
using Unity.Payments.Domain.PaymentRequests;
using Volo.Abp.DependencyInjection;
-using Unity.Payments.Codes;
using System.Net.Http;
using Microsoft.Extensions.Logging;
-using Volo.Abp.Uow;
using Unity.Modules.Shared.Http;
-using Unity.Payments.PaymentConfigurations;
-using Unity.Payments.Domain.AccountCodings;
using Unity.GrantManager.Integrations;
+using Unity.Payments.Domain.Services;
namespace Unity.Payments.Integrations.Cas
{
[IntegrationService]
[ExposeServices(typeof(InvoiceService), typeof(IInvoiceService))]
-#pragma warning disable S107 // Methods should not have too many parameters
public class InvoiceService(
IEndpointManagementAppService endpointManagementAppService,
ICasTokenService iTokenService,
- IAccountCodingRepository accountCodingRepository,
- PaymentConfigurationAppService paymentConfigurationAppService,
- IPaymentRequestRepository paymentRequestRepository,
IResilientHttpRequest resilientHttpRequest,
- ISupplierRepository iSupplierRepository,
- ISiteRepository iSiteRepository,
- IUnitOfWorkManager unitOfWorkManager) : ApplicationService, IInvoiceService
-#pragma warning restore S107 // Methods should not have too many parameters
+ IInvoiceManager invoiceManager) : ApplicationService, IInvoiceService
{
private const string CFS_APINVOICE = "cfs/apinvoice";
@@ -50,7 +38,7 @@ public class InvoiceService(
string? accountDistributionCode)
{
Invoice? casInvoice = new();
- Site? site = await GetSiteByPaymentRequestAsync(paymentRequest);
+ Site? site = await invoiceManager.GetSiteByPaymentRequestAsync(paymentRequest);
if (site != null && site.Supplier != null && site.Supplier.Number != null && accountDistributionCode != null)
{
@@ -86,41 +74,23 @@ public class InvoiceService(
return casInvoice;
}
- public async Task GetSiteByPaymentRequestAsync(PaymentRequest paymentRequest)
- {
- Site? site = await iSiteRepository.GetAsync(paymentRequest.SiteId, true);
- if (site?.SupplierId != null)
- {
- Supplier supplier = await iSupplierRepository.GetAsync(site.SupplierId);
- site.Supplier = supplier;
- }
- return site;
- }
-
public async Task CreateInvoiceByPaymentRequestAsync(string invoiceNumber)
{
InvoiceResponse invoiceResponse = new();
try
{
- var paymentRequest = await paymentRequestRepository.GetPaymentRequestByInvoiceNumber(invoiceNumber)
- ?? throw new UserFriendlyException("CreateInvoiceByPaymentRequestAsync: Payment Request not found");
+ var paymentRequestData = await invoiceManager.GetPaymentRequestDataAsync(invoiceNumber);
- if (!paymentRequest.AccountCodingId.HasValue)
- throw new UserFriendlyException("CreateInvoiceByPaymentRequestAsync: Account Coding - Payment Request - not found");
-
- AccountCoding accountCoding = await accountCodingRepository.GetAsync(paymentRequest.AccountCodingId.Value);
- string accountDistributionCode = await paymentConfigurationAppService.GetAccountDistributionCode(accountCoding);// this will be on the payment request
-
- if (!string.IsNullOrEmpty(accountDistributionCode))
+ if (!string.IsNullOrEmpty(paymentRequestData.AccountDistributionCode))
{
- Invoice? invoice = await InitializeCASInvoice(paymentRequest, accountDistributionCode);
+ Invoice? invoice = await InitializeCASInvoice(paymentRequestData.PaymentRequest, paymentRequestData.AccountDistributionCode);
if (invoice is not null)
{
invoiceResponse = await CreateInvoiceAsync(invoice);
if (invoiceResponse is not null)
{
- await UpdatePaymentRequestWithInvoice(paymentRequest.Id, invoiceResponse);
+ await invoiceManager.UpdatePaymentRequestWithInvoiceAsync(paymentRequestData.PaymentRequest.Id, invoiceResponse);
}
}
}
@@ -134,104 +104,6 @@ public class InvoiceService(
return invoiceResponse;
}
- private async Task UpdatePaymentRequestWithInvoice(Guid paymentRequestId, InvoiceResponse invoiceResponse)
- {
- const int maxRetries = 3;
-
- for (int attempt = 1; attempt <= maxRetries; attempt++)
- {
- try
- {
- // Each attempt must have a fresh UoW
- using (var uow = unitOfWorkManager.Begin())
- {
- // Load with tracking
- var paymentRequest = await paymentRequestRepository.GetAsync(paymentRequestId);
-
- if (paymentRequest == null)
- {
- Logger.LogWarning("PaymentRequest {Id} not found. Skipping update.", paymentRequestId);
- return;
- }
-
- // Idempotency: do not re-process
- if (paymentRequest.InvoiceStatus == CasPaymentRequestStatus.SentToCas)
- {
- Logger.LogInformation(
- "PaymentRequest {Id} already invoiced. Skipping update.",
- paymentRequestId
- );
- return;
- }
-
- // Apply CAS response info
- paymentRequest.SetCasHttpStatusCode((int)invoiceResponse.CASHttpStatusCode);
- paymentRequest.SetCasResponse(invoiceResponse.CASReturnedMessages);
-
- // Set status
- paymentRequest.SetInvoiceStatus(
- invoiceResponse.IsSuccess()
- ? CasPaymentRequestStatus.SentToCas
- : CasPaymentRequestStatus.ErrorFromCas
- );
-
- await paymentRequestRepository.UpdateAsync(paymentRequest, autoSave: false);
-
- // Commit this attempt
- await uow.CompleteAsync();
-
- Logger.LogInformation(
- "PaymentRequest {Id} updated successfully on attempt {Attempt}.",
- paymentRequestId,
- attempt
- );
- return; // success
- }
- }
- catch (Exception ex) when (
- ex is AbpDbConcurrencyException ||
- ex is DbUpdateConcurrencyException
- ) {
- Logger.LogWarning(
- ex,
- "Concurrency conflict when updating PaymentRequest {Id}, attempt {Attempt}",
- paymentRequestId,
- attempt
- );
-
- if (attempt == maxRetries)
- {
- Logger.LogError(
- ex,
- "Max retries reached for PaymentRequest {Id}. Manual intervention may be required.",
- paymentRequestId
- );
-
- throw new UserFriendlyException(
- $"Failed to update payment request {paymentRequestId} after {maxRetries} attempts due to concurrency conflicts."
- );
- }
-
- // Brief pause before retrying to reduce immediate collision
- await Task.Delay(75);
- }
- catch (Exception ex)
- {
- Logger.LogError(
- ex,
- "Unexpected exception updating PaymentRequest {Id} on attempt {Attempt}",
- paymentRequestId,
- attempt
- );
-
- throw new UserFriendlyException(
- $"Failed to update payment request {paymentRequestId}: {ex.Message}"
- );
- }
- }
- }
-
-
public async Task CreateInvoiceAsync(Invoice casAPInvoice)
{
string jsonString = JsonSerializer.Serialize(casAPInvoice);
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 235636cd1..f8ada042e 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
@@ -15,7 +15,6 @@ namespace Unity.Payments.PaymentRequests;
[DisallowConcurrentExecution]
public class FinancialNotificationSummaryWorker : QuartzBackgroundWorkerBase
{
- private readonly ILogger _logger;
private readonly FinancialSummaryNotifier _financialSummaryNotifier;
private readonly IEnumerable _strategies;
@@ -26,10 +25,9 @@ public FinancialNotificationSummaryWorker(
IEnumerable strategies)
{
_financialSummaryNotifier = financialSummaryNotifier;
- _logger = logger;
_strategies = strategies;
- _logger.LogInformation("FinancialNotificationSummary Constructor: Email strategies registered.");
+ logger.LogInformation("FinancialNotificationSummary Constructor: Email strategies registered.");
string casFinancialNotificationExpression = "";
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/Notifications/FsbPaymentExcelGenerator.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/Notifications/FsbPaymentExcelGenerator.cs
index 9d208c1fb..058916794 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/Notifications/FsbPaymentExcelGenerator.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/Notifications/FsbPaymentExcelGenerator.cs
@@ -41,6 +41,14 @@ public class FsbPaymentExcelGenerator : ISingletonDependency
private const string SheetName = "FSB Payments";
private const string DATE_FORMAT = "yyyy-MM-dd HH:mm:ss";
+ private const string PacificTimeZoneId = "Pacific Standard Time";
+ private static readonly string[] PacificTimeZoneIanaIds =
+ [
+ "America/Vancouver",
+ "America/Los_Angeles"
+ ];
+ private static readonly TimeZoneInfo PacificTimeZone = ResolvePacificTimeZone();
+ private static readonly bool PacificTimeZoneIsUtcFallback = PacificTimeZone.Id == TimeZoneInfo.Utc.Id;
///
/// Generates an Excel file from a list of FSB payment data
@@ -112,21 +120,61 @@ private static void AddPaymentRow(IXLWorksheet worksheet, int rowNumber, FsbPaym
worksheet.Cell(rowNumber, 3).Value = payment.PayeeName ?? "N/A";
worksheet.Cell(rowNumber, 4).Value = payment.CasSupplierSiteNumber ?? "N/A";
worksheet.Cell(rowNumber, 5).Value = payment.PayeeAddress ?? "N/A";
- worksheet.Cell(rowNumber, 6).Value = payment.InvoiceDate?.ToString(DATE_FORMAT) ?? "N/A";
+ worksheet.Cell(rowNumber, 6).Value = FormatDate(payment.InvoiceDate);
worksheet.Cell(rowNumber, 7).Value = payment.InvoiceNumber ?? "N/A";
worksheet.Cell(rowNumber, 8).Value = payment.Amount;
worksheet.Cell(rowNumber, 9).Value = payment.PayGroup ?? "N/A";
- worksheet.Cell(rowNumber, 10).Value = payment.GoodsServicesReceivedDate?.ToString(DATE_FORMAT) ?? "N/A";
+ worksheet.Cell(rowNumber, 10).Value = FormatDate(payment.GoodsServicesReceivedDate);
worksheet.Cell(rowNumber, 11).Value = payment.QualifierReceiver ?? "N/A";
- worksheet.Cell(rowNumber, 12).Value = payment.QRApprovalDate?.ToString(DATE_FORMAT) ?? "N/A";
+ worksheet.Cell(rowNumber, 12).Value = FormatDate(payment.QRApprovalDate);
worksheet.Cell(rowNumber, 13).Value = payment.ExpenseAuthority ?? "N/A";
- worksheet.Cell(rowNumber, 14).Value = payment.EAApprovalDate?.ToString(DATE_FORMAT) ?? "N/A";
+ worksheet.Cell(rowNumber, 14).Value = FormatDate(payment.EAApprovalDate);
worksheet.Cell(rowNumber, 15).Value = payment.CasCheckStubDescription ?? "N/A";
worksheet.Cell(rowNumber, 16).Value = payment.AccountCoding ?? "N/A";
worksheet.Cell(rowNumber, 17).Value = payment.PaymentRequester ?? "N/A";
- worksheet.Cell(rowNumber, 18).Value = payment.RequestedOn?.ToString(DATE_FORMAT) ?? "N/A";
+ worksheet.Cell(rowNumber, 18).Value = FormatDate(payment.RequestedOn);
worksheet.Cell(rowNumber, 19).Value = payment.L3Approver ?? "N/A";
- worksheet.Cell(rowNumber, 20).Value = payment.L3ApprovalDate?.ToString(DATE_FORMAT) ?? "N/A";
+ worksheet.Cell(rowNumber, 20).Value = FormatDate(payment.L3ApprovalDate);
+ }
+
+ private static string FormatDate(DateTime? utcDateTime)
+ {
+ if (!utcDateTime.HasValue)
+ {
+ return "N/A";
+ }
+
+ var normalizedUtc = DateTime.SpecifyKind(utcDateTime.Value, DateTimeKind.Utc);
+ var pacificTime = TimeZoneInfo.ConvertTime(new DateTimeOffset(normalizedUtc), PacificTimeZone);
+ var tzAbbreviation = "UTC";
+ if (!PacificTimeZoneIsUtcFallback)
+ {
+ tzAbbreviation = PacificTimeZone.IsDaylightSavingTime(pacificTime.DateTime) ? "PDT" : "PST";
+ }
+ return $"{pacificTime.ToString(DATE_FORMAT)} {tzAbbreviation}";
+ }
+
+ private static TimeZoneInfo ResolvePacificTimeZone()
+ {
+ if (TimeZoneInfo.TryFindSystemTimeZoneById(PacificTimeZoneId, out var timeZone))
+ {
+ return timeZone;
+ }
+
+ return TryResolveIanaTimeZone();
+ }
+
+ private static TimeZoneInfo TryResolveIanaTimeZone()
+ {
+ foreach (var timeZoneId in PacificTimeZoneIanaIds)
+ {
+ if (TimeZoneInfo.TryFindSystemTimeZoneById(timeZoneId, out var timeZone))
+ {
+ return timeZone;
+ }
+ }
+
+ return TimeZoneInfo.Utc;
}
}
}
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/Notifications/FsbPaymentNotifier.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/Notifications/FsbPaymentNotifier.cs
index 1514720fc..ed1e18b70 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/Notifications/FsbPaymentNotifier.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/Notifications/FsbPaymentNotifier.cs
@@ -1,6 +1,7 @@
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
+using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Unity.Notifications.Emails;
@@ -62,8 +63,6 @@ public async Task NotifyFsbPayments(List fsbPayments)
try
{
- _logger.LogInformation("NotifyFsbPayments: Processing {Count} FSB payments", fsbPayments.Count);
-
// Get recipients from FSB-AP email group
var recipients = await _fsbApEmailGroupStrategy.GetEmailRecipientsAsync();
if (recipients == null || recipients.Count == 0)
@@ -72,17 +71,15 @@ public async Task NotifyFsbPayments(List fsbPayments)
return;
}
- // Collect payment data for Excel
- var paymentDataList = await CollectPaymentData(fsbPayments);
- if (paymentDataList.Count == 0)
- {
- _logger.LogWarning("NotifyFsbPayments: Failed to collect payment data. Email not sent.");
- return;
- }
+ // Group payments by batch name (treating null/empty as "Unknown")
+ var batchGroups = fsbPayments
+ .GroupBy(p => string.IsNullOrWhiteSpace(p.BatchName) ? "Unknown" : p.BatchName)
+ .ToList();
- // Generate Excel file
- byte[] excelBytes = FsbPaymentExcelGenerator.GenerateExcelFile(paymentDataList);
- string fileName = $"FSB_Payments_{DateTime.UtcNow:yyyyMMdd_HHmmss}.xlsx";
+ _logger.LogInformation(
+ "NotifyFsbPayments: Grouped {TotalPayments} payments into {BatchCount} batches",
+ fsbPayments.Count,
+ batchGroups.Count);
// Get tenant name for email body
string tenantName = "N/A";
@@ -92,43 +89,57 @@ public async Task NotifyFsbPayments(List fsbPayments)
tenantName = tenant?.Name ?? "N/A";
}
- // Generate email body
- string emailBody = GenerateEmailBody(tenantName);
-
// Get from address
var defaultFromAddress = await _settingProvider.GetOrNullAsync(NotificationsSettings.Mailing.DefaultFromAddress);
string fromAddress = defaultFromAddress ?? "NoReply@gov.bc.ca";
- // Publish email event with attachment
- await _localEventBus.PublishAsync(
- new EmailNotificationEvent
+ // Process each batch
+ int successCount = 0;
+ int failureCount = 0;
+
+ foreach (var batchGroup in batchGroups)
+ {
+ string batchName = batchGroup.Key;
+ var batchPayments = batchGroup.ToList();
+
+ try
+ {
+ await SendBatchNotification(
+ batchName,
+ batchPayments,
+ recipients,
+ tenantName,
+ fromAddress);
+
+ successCount++;
+ _logger.LogInformation(
+ "NotifyFsbPayments: Successfully sent notification for batch '{BatchName}' with {PaymentCount} payments",
+ batchName,
+ batchPayments.Count);
+ }
+ catch (Exception ex)
{
- Action = EmailAction.SendFsbNotification,
- TenantId = _currentTenant.Id,
- RetryAttempts = 0,
- Body = emailBody,
- Subject = "FSB Payment Notification",
- EmailFrom = fromAddress,
- EmailAddressList = recipients,
- ApplicationId = Guid.Empty, // System-level email, not application-specific
- EmailAttachments =
- [
- new() {
- FileName = fileName,
- Content = excelBytes, // Byte array, not Base64
- ContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
- }
- ]
+ failureCount++;
+ _logger.LogError(
+ ex,
+ "NotifyFsbPayments: Failed to send notification for batch '{BatchName}' with {PaymentCount} payments. Continuing with other batches.",
+ batchName,
+ batchPayments.Count);
+ // Continue processing other batches (resilient processing)
}
- );
+ }
- _logger.LogInformation("NotifyFsbPayments: Email notification published successfully for {Count} payments", fsbPayments.Count);
+ _logger.LogInformation(
+ "NotifyFsbPayments: Completed processing {TotalBatches} batches. Success: {SuccessCount}, Failed: {FailureCount}",
+ batchGroups.Count,
+ successCount,
+ failureCount);
}
catch (Exception ex)
{
- _logger.LogError(ex, "NotifyFsbPayments: Error sending FSB payment notification");
+ _logger.LogError(ex, "NotifyFsbPayments: Critical error during batch processing");
throw new InvalidOperationException(
- $"Failed to send FSB payment notification. See inner exception for details.",
+ $"Failed to process FSB payment notifications. See inner exception for details.",
ex);
}
}
@@ -275,7 +286,7 @@ private static string FormatPayGroup(PaymentGroup paymentGroup)
private static void ApplyPaymentRequester(
FsbPaymentData paymentData,
PaymentRequest payment,
- IReadOnlyDictionary userNameDict)
+ Dictionary userNameDict)
{
// Column 17: Payment Requester
if (!payment.CreatorId.HasValue)
@@ -424,5 +435,107 @@ private static string FormatAddress(
return addressParts.Count > 0 ? string.Join(", ", addressParts) : "N/A";
}
+ ///
+ /// Sends email notification for a single batch of payments
+ ///
+ /// Name of the batch (already normalized for "Unknown")
+ /// List of payment requests in this batch
+ /// Email recipients list
+ /// Current tenant name for email body
+ /// Email from address
+ private async Task SendBatchNotification(
+ string batchName,
+ List batchPayments,
+ List recipients,
+ string tenantName,
+ string fromAddress)
+ {
+ // Collect payment data for this batch
+ var paymentDataList = await CollectPaymentData(batchPayments);
+ if (paymentDataList.Count == 0)
+ {
+ _logger.LogWarning(
+ "SendBatchNotification: Failed to collect payment data for batch '{BatchName}'. Email not sent.",
+ batchName);
+ return;
+ }
+
+ // Generate Excel file
+ byte[] excelBytes = FsbPaymentExcelGenerator.GenerateExcelFile(paymentDataList);
+
+ // Generate filename with sanitized batch name
+ string sanitizedBatchName = SanitizeFileName(batchName);
+ string fileName = $"FSB_Payments_{sanitizedBatchName}_{DateTime.UtcNow:yyyyMMdd_HHmmssfff}.xlsx";
+
+ // Generate email body (reuse existing method)
+ string emailBody = GenerateEmailBody(tenantName);
+
+ // Generate email subject per requirement
+ string subject = batchName;
+
+ // Extract payment IDs for tracking
+ var paymentIds = batchPayments.Select(p => p.Id).ToList();
+
+ // Publish email event with attachment
+ await _localEventBus.PublishAsync(
+ new EmailNotificationEvent
+ {
+ Action = EmailAction.SendFsbNotification,
+ TenantId = _currentTenant.Id,
+ RetryAttempts = 0,
+ Body = emailBody,
+ Subject = subject, // Batch-specific subject
+ EmailFrom = fromAddress,
+ EmailAddressList = recipients,
+ ApplicationId = Guid.Empty,
+ EmailAttachments =
+ [
+ new() {
+ FileName = fileName, // Batch-specific filename
+ Content = excelBytes,
+ ContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+ }
+ ],
+ PaymentRequestIds = paymentIds // Track which payments are in this email
+ }
+ );
+ }
+
+ ///
+ /// Sanitizes batch name for use in filenames by removing invalid characters
+ ///
+ /// Original batch name
+ /// Sanitized batch name safe for filenames
+ private static string SanitizeFileName(string batchName)
+ {
+ if (string.IsNullOrWhiteSpace(batchName))
+ {
+ return "Unknown";
+ }
+
+ // Get OS-specific invalid filename characters
+ char[] invalidChars = Path.GetInvalidFileNameChars();
+
+ // Replace invalid characters with underscore
+ string sanitized = batchName;
+ foreach (char c in invalidChars)
+ {
+ sanitized = sanitized.Replace(c, '_');
+ }
+
+ // Replace spaces with underscores for cleaner filenames
+ sanitized = sanitized.Replace(' ', '_');
+
+ // Trim to reasonable length (Windows has 255 char limit)
+ // Reserve space for: "FSB_Payments_" (13) + "_yyyyMMdd_HHmmssfff.xlsx" (25) = 38 chars
+ const int maxBatchNameLength = 217; // Conservative limit (255 - 38 = 217)
+ if (sanitized.Length > maxBatchNameLength)
+ {
+ sanitized = sanitized.Substring(0, maxBatchNameLength);
+ }
+
+ return sanitized;
+ }
+
}
}
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs
index fc4946661..81c63c3b8 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/PaymentRequestAppService.cs
@@ -1,12 +1,10 @@
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.EntityFrameworkCore;
+using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Unity.Payments.Domain.Exceptions;
-using Unity.Payments.Domain.PaymentConfigurations;
using Unity.Payments.Domain.PaymentRequests;
using Unity.Payments.Domain.Services;
using Unity.Payments.Domain.Shared;
@@ -18,52 +16,32 @@
using Volo.Abp.Features;
using Volo.Abp.Authorization.Permissions;
using Volo.Abp.Users;
-using Unity.Payments.Domain.PaymentThresholds;
-using Volo.Abp.Domain.Repositories;
-using Unity.GrantManager.Applications;
-using Unity.Payments.Domain.Suppliers;
-using Unity.Payments.Suppliers;
using Unity.Payments.PaymentRequests.Notifications;
namespace Unity.Payments.PaymentRequests
{
[RequiresFeature("Unity.Payments")]
[Authorize]
- #pragma warning disable S107 // Suppress "Constructor has too many parameters"
- public class PaymentRequestAppService(
+ public class PaymentRequestAppService(
ICurrentUser currentUser,
IDataFilter dataFilter,
- IExternalUserLookupServiceProvider externalUserLookupServiceProvider,
- IApplicationRepository applicationRepository,
- IApplicationFormRepository applicationFormRepository,
- IPaymentConfigurationRepository paymentConfigurationRepository,
- IPaymentsManager paymentsManager,
- IPaymentRequestRepository paymentRequestsRepository,
- IPaymentThresholdRepository paymentThresholdRepository,
IPermissionChecker permissionChecker,
- ISiteRepository siteRepository,
- CasPaymentRequestCoordinator casPaymentRequestCoordinator,
- FsbPaymentNotifier fsbPaymentNotifier) : PaymentsAppService, IPaymentRequestAppService
- #pragma warning restore S107
+ IPaymentsManager paymentsManager,
+ FsbPaymentNotifier fsbPaymentNotifier,
+ IPaymentRequestQueryManager paymentRequestQueryManager,
+ IPaymentRequestConfigurationManager paymentRequestConfigurationManager) : PaymentsAppService, IPaymentRequestAppService
{
public async Task GetDefaultAccountCodingId()
{
- Guid? accountCodingId = null;
- // If no account coding is found look up the payment configuration
- PaymentConfiguration? paymentConfiguration = await GetPaymentConfigurationAsync();
- if (paymentConfiguration != null && paymentConfiguration.DefaultAccountCodingId.HasValue)
- {
- accountCodingId = paymentConfiguration.DefaultAccountCodingId;
- }
- return accountCodingId;
+ return await paymentRequestConfigurationManager.GetDefaultAccountCodingIdAsync();
}
[Authorize(PaymentsPermissions.Payments.RequestPayment)]
public virtual async Task> CreateAsync(List paymentRequests)
{
List createdPayments = [];
- var paymentConfig = await GetPaymentConfigurationAsync();
+ var paymentConfig = await paymentRequestConfigurationManager.GetPaymentConfigurationAsync();
var paymentIdPrefix = string.Empty;
if (paymentConfig != null && !paymentConfig.PaymentIdPrefix.IsNullOrEmpty())
@@ -71,10 +49,10 @@ public virtual async Task> CreateAsync(List new { i, value }))
{
@@ -82,18 +60,18 @@ public virtual async Task> CreateAsync(List> CreateAsync(List GetNextBatchInfoAsync()
{
- var paymentConfig = await GetPaymentConfigurationAsync();
- var paymentIdPrefix = string.Empty;
-
- if (paymentConfig != null && !paymentConfig.PaymentIdPrefix.IsNullOrEmpty())
- {
- paymentIdPrefix = paymentConfig.PaymentIdPrefix;
- }
-
- var batchNumber = await GetMaxBatchNumberAsync();
- var batchName = $"{paymentIdPrefix}_UNITY_BATCH_{batchNumber}";
-
- return batchName;
- }
-
- private static string GenerateInvoiceNumberAsync(string referenceNumber, string invoiceNumber, string sequencePart)
- {
- return $"{referenceNumber}-{invoiceNumber}-{sequencePart}";
- }
-
- private static string GenerateReferenceNumberAsync(string referenceNumber, string sequencePart)
- {
- return $"{referenceNumber}-{sequencePart}";
- }
-
-
- private static string GenerateSequenceNumberAsync(int sequenceNumber, int index)
- {
- sequenceNumber += index;
- return sequenceNumber.ToString("D4");
- }
-
- private static string GenerateReferenceNumberPrefixAsync(string paymentIdPrefix)
- {
- var currentYear = DateTime.UtcNow.Year;
- var yearPart = currentYear.ToString();
- return $"{paymentIdPrefix}-{yearPart}";
- }
-
- private async Task GetMaxBatchNumberAsync()
- {
- var paymentRequestList = await paymentRequestsRepository.GetListAsync();
- decimal batchNumber = 1; // Lookup max plus 1
- if (paymentRequestList != null && paymentRequestList.Count > 0)
- {
- var maxBatchNumber = paymentRequestList.Max(s => s.BatchNumber);
-
- if (maxBatchNumber > 0)
- {
- batchNumber = maxBatchNumber + 1;
- }
- }
-
- return batchNumber;
+ return await paymentRequestConfigurationManager.GetNextBatchInfoAsync();
}
public Task GetPaymentRequestCountBySiteIdAsync(Guid siteId)
{
- return paymentRequestsRepository.GetPaymentRequestCountBySiteId(siteId);
+ return paymentRequestQueryManager.GetPaymentRequestCountBySiteIdAsync(siteId);
}
public virtual async Task> UpdateStatusAsync(List paymentRequests)
@@ -189,10 +115,10 @@ public virtual async Task> UpdateStatusAsync(List r.IsApprove).Select(x => x.PaymentRequestId).ToList();
- var approvalList = await paymentRequestsRepository.GetListAsync(x => approvalRequests.Contains(x.Id), includeDetails: true);
+ var approvalList = await paymentRequestQueryManager.GetPaymentRequestsByIdsAsync(approvalRequests, includeDetails: true);
// Rule AB#26693: Reject Payment Request update batch if violates L1 and L2 separation of duties
- if (approvalList.Any(
+ if (approvalList.Exists(
x => x.Status == PaymentRequestStatus.L2Pending
&& CurrentUser.Id == x.ExpenseApprovals.FirstOrDefault(y => y.Type == ExpenseApprovalType.Level1)?.DecisionUserId))
{
@@ -205,7 +131,7 @@ public virtual async Task> UpdateStatusAsync(List> UpdateStatusAsync(List> UpdateStatusAsync(List fsbPaymentIds.Contains(p.Id),
- includeDetails: true);
+ var fsbPayments = await paymentRequestQueryManager.GetPaymentRequestsByIdsAsync(fsbPaymentIds, includeDetails: true);
await fsbPaymentNotifier.NotifyFsbPayments(fsbPayments);
}
@@ -297,12 +221,12 @@ private async Task GetLevel2ApprovalActionAsync(UpdatePay
{
if (!dto.IsApprove)
return PaymentApprovalAction.L2Decline;
-
+
decimal? threshold = null;
try
{
decimal? userPaymentThreshold = await GetUserPaymentThresholdAsync();
- threshold = await GetPaymentRequestThresholdByApplicationIdAsync(payment.CorrelationId, userPaymentThreshold);
+ threshold = await paymentRequestConfigurationManager.GetPaymentRequestThresholdByApplicationIdAsync(payment.CorrelationId, userPaymentThreshold);
}
catch (Exception ex)
{
@@ -314,26 +238,12 @@ private async Task GetLevel2ApprovalActionAsync(UpdatePay
return PaymentApprovalAction.Submit;
}
+
public async Task GetPaymentRequestThresholdByApplicationIdAsync(Guid applicationId, decimal? userPaymentThreshold = null)
{
- var application = await (await applicationRepository.GetQueryableAsync())
- .Include(a => a.ApplicationForm)
- .FirstOrDefaultAsync(a => a.Id == applicationId) ?? throw new BusinessException($"Application with Id {applicationId} not found.");
- var appForm = application.ApplicationForm ??
- (application.ApplicationFormId != Guid.Empty
- ? await applicationFormRepository.GetAsync(application.ApplicationFormId)
- : null);
-
- var formThreshold = appForm?.PaymentApprovalThreshold;
-
- if (formThreshold.HasValue && userPaymentThreshold.HasValue)
- {
- return Math.Min(formThreshold.Value, userPaymentThreshold.Value);
- }
-
- return formThreshold ?? userPaymentThreshold ?? 0m;
+ return await paymentRequestConfigurationManager.GetPaymentRequestThresholdByApplicationIdAsync(applicationId, userPaymentThreshold);
}
-
+
private async Task CanPerformLevel1ActionAsync(PaymentRequestStatus status)
{
List level1Approvals = [PaymentRequestStatus.L1Pending, PaymentRequestStatus.L1Declined];
@@ -361,174 +271,47 @@ private async Task CanPerformLevel3ActionAsync(PaymentRequestStatus status
return await permissionChecker.IsGrantedAsync(PaymentsPermissions.Payments.L3ApproveOrDecline) && level3Approvals.Contains(status);
}
- private async Task CreatePaymentRequestDtoAsync(Guid paymentRequestId)
- {
- var payment = await paymentRequestsRepository.GetAsync(paymentRequestId);
- return new PaymentRequestDto
- {
- Id = payment.Id,
- InvoiceNumber = payment.InvoiceNumber,
- InvoiceStatus = payment.InvoiceStatus,
- Amount = payment.Amount,
- PayeeName = payment.PayeeName,
- SupplierNumber = payment.SupplierNumber,
- ContractNumber = payment.ContractNumber,
- CorrelationId = payment.CorrelationId,
- CorrelationProvider = payment.CorrelationProvider,
- Description = payment.Description,
- CreationTime = payment.CreationTime,
- Status = payment.Status,
- ReferenceNumber = payment.ReferenceNumber,
- SubmissionConfirmationCode = payment.SubmissionConfirmationCode,
- Note = payment.Note
- };
- }
-
public async Task> GetListByApplicationIdsAsync(List applicationIds)
{
- var paymentsQueryable = await paymentRequestsRepository.GetQueryableAsync();
- var payments = await paymentsQueryable.Include(pr => pr.Site).ToListAsync();
- var filteredPayments = payments.Where(pr => applicationIds.Contains(pr.CorrelationId)).ToList();
-
- return ObjectMapper.Map, List>(filteredPayments);
+ return await paymentRequestQueryManager.GetListByApplicationIdsAsync(applicationIds);
}
public async Task> GetListAsync(PagedAndSortedResultRequestDto input)
{
- var totalCount = await paymentRequestsRepository.GetCountAsync();
+ var totalCount = await paymentRequestQueryManager.GetPaymentRequestCountAsync();
using (dataFilter.Disable())
{
- await paymentRequestsRepository
- .GetPagedListAsync(input.SkipCount, input.MaxResultCount, input.Sorting ?? string.Empty, includeDetails: true);
-
- // Include PaymentTags in the query
- var paymentsQueryable = await paymentRequestsRepository.GetQueryableAsync();
- // Changing this breaks the code so suppressing the warning
-#pragma warning disable CS8620 // Argument cannot be used for parameter due to differences in the nullability of reference types.
- var paymentWithIncludes = await paymentsQueryable
- .Include(pr => pr.AccountCoding)
- .Include(pr => pr.PaymentTags)
- .ThenInclude(pt => pt.Tag)
- .ToListAsync();
-#pragma warning restore CS8620 // Argument cannot be used for parameter due to differences in the nullability of reference types.
+ var paymentWithIncludes = await paymentRequestQueryManager.GetPagedPaymentRequestsWithIncludesAsync(input.SkipCount, input.MaxResultCount, input.Sorting ?? string.Empty);
- var mappedPayments = await MapToDtoAndLoadDetailsAsync(paymentWithIncludes);
+ var mappedPayments = await paymentRequestQueryManager.MapToDtoAndLoadDetailsAsync(paymentWithIncludes);
- ApplyErrorSummary(mappedPayments);
+ paymentRequestQueryManager.ApplyErrorSummary(mappedPayments);
return new PagedResultDto(totalCount, mappedPayments);
}
}
- protected internal async Task> MapToDtoAndLoadDetailsAsync(List paymentsList)
- {
- var paymentDtos = ObjectMapper.Map, List>(paymentsList);
-
- // Flatten all DecisionUserIds from ExpenseApprovals across all PaymentRequestDtos
- List paymentRequesterIds = [.. paymentDtos
- .Select(payment => payment.CreatorId)
- .OfType()
- .Distinct()];
-
- List expenseApprovalCreatorIds = [.. paymentDtos
- .SelectMany(payment => payment.ExpenseApprovals)
- .Where(expenseApproval => expenseApproval.Status != ExpenseApprovalStatus.Requested)
- .Select(expenseApproval => expenseApproval.DecisionUserId)
- .OfType()
- .Distinct()];
-
- // Call external lookup for each distinct User Id and store in a dictionary.
- var userDictionary = new Dictionary();
- var allUserIds = paymentRequesterIds.Concat(expenseApprovalCreatorIds).Distinct();
- foreach (var userId in allUserIds)
- {
- var userInfo = await externalUserLookupServiceProvider.FindByIdAsync(userId);
- if (userInfo != null)
- {
- userDictionary[userId] = ObjectMapper.Map(userInfo);
- }
- }
-
- // Map UserInfo details to each ExpenseApprovalDto
- foreach (var paymentRequestDto in paymentDtos)
- {
- if (paymentRequestDto.CreatorId.HasValue
- && userDictionary.TryGetValue(paymentRequestDto.CreatorId.Value, out var paymentRequestUserDto))
- {
- paymentRequestDto.CreatorUser = paymentRequestUserDto;
- }
-
- if(paymentRequestDto != null && paymentRequestDto.AccountCoding != null)
- {
- paymentRequestDto.AccountCodingDisplay = await GetAccountDistributionCode(paymentRequestDto.AccountCoding);
- }
-
- if (paymentRequestDto != null && paymentRequestDto.ExpenseApprovals != null)
- {
- foreach (var expenseApproval in paymentRequestDto.ExpenseApprovals)
- {
- if (expenseApproval.DecisionUserId.HasValue
- && userDictionary.TryGetValue(expenseApproval.DecisionUserId.Value, out var expenseApprovalUserDto))
- {
- expenseApproval.DecisionUser = expenseApprovalUserDto;
- }
- }
- }
- }
-
- return paymentDtos;
- }
-
- public virtual Task GetAccountDistributionCode(AccountCodingDto? accountCoding)
- {
- return Task.FromResult(AccountCodingFormatter.Format(accountCoding));
- }
-
- private static void ApplyErrorSummary(List mappedPayments)
- {
- mappedPayments.ForEach(mappedPayment =>
- {
- if (!string.IsNullOrWhiteSpace(mappedPayment.CasResponse) &&
- !mappedPayment.CasResponse.Equals("SUCCEEDED", StringComparison.OrdinalIgnoreCase))
- {
- mappedPayment.ErrorSummary = mappedPayment.CasResponse;
- }
- });
- }
-
public async Task> GetListByApplicationIdAsync(Guid applicationId)
{
using (dataFilter.Disable())
{
- var paymentsQueryable = await paymentRequestsRepository.GetQueryableAsync();
- var payments = await paymentsQueryable.Include(pr => pr.Site).ToListAsync();
- var filteredPayments = payments.Where(e => e.CorrelationId == applicationId).ToList();
-
- return ObjectMapper.Map, List>(filteredPayments);
+ return await paymentRequestQueryManager.GetListByApplicationIdAsync(applicationId);
}
}
public async Task> GetListByPaymentIdsAsync(List paymentIds)
{
- var paymentsQueryable = await paymentRequestsRepository.GetQueryableAsync();
- var payments = await paymentsQueryable
- .Where(e => paymentIds.Contains(e.Id))
- .Include(pr => pr.Site)
- .Include(x => x.ExpenseApprovals)
- .ToListAsync();
-
- return ObjectMapper.Map, List>(payments);
+ return await paymentRequestQueryManager.GetListByPaymentIdsAsync(paymentIds);
}
public virtual async Task GetTotalPaymentRequestAmountByCorrelationIdAsync(Guid correlationId)
{
- return await paymentRequestsRepository.GetTotalPaymentRequestAmountByCorrelationIdAsync(correlationId);
+ return await paymentRequestQueryManager.GetTotalPaymentRequestAmountByCorrelationIdAsync(correlationId);
}
public async Task GetUserPaymentThresholdAsync()
{
- var userThreshold = await paymentThresholdRepository.FirstOrDefaultAsync(x => x.UserId == currentUser.Id);
- return userThreshold?.Threshold;
+ return await paymentRequestConfigurationManager.GetUserPaymentThresholdAsync(currentUser.Id);
}
protected virtual string GetCurrentRequesterName()
@@ -536,62 +319,14 @@ protected virtual string GetCurrentRequesterName()
return $"{currentUser.Name} {currentUser.SurName}";
}
- protected virtual async Task GetPaymentConfigurationAsync()
- {
- var paymentConfigs = await paymentConfigurationRepository.GetListAsync();
-
- if (paymentConfigs.Count > 0)
- {
- var paymentConfig = paymentConfigs[0];
- return paymentConfig;
- }
-
- return null;
- }
-
public async Task ManuallyAddPaymentRequestsToReconciliationQueue(List paymentRequestIds)
{
- List paymentRequestDtos = [];
- foreach (var paymentRequestId in paymentRequestIds)
- {
- var paymentRequest = await paymentRequestsRepository.GetAsync(paymentRequestId);
- if (paymentRequest != null)
- {
- var paymentRequestDto = ObjectMapper.Map(paymentRequest);
- Site site = await siteRepository.GetAsync(paymentRequest.SiteId);
- paymentRequestDto.Site = ObjectMapper.Map(site);
- paymentRequestDtos.Add(paymentRequestDto);
- }
- }
- await casPaymentRequestCoordinator.ManuallyAddPaymentRequestsToReconciliationQueue(paymentRequestDtos);
+ await paymentRequestQueryManager.ManuallyAddPaymentRequestsToReconciliationQueueAsync(paymentRequestIds);
}
-
- private async Task GetNextSequenceNumberAsync(int currentYear)
- {
- // Retrieve all payment requests
- var payments = await paymentRequestsRepository.GetListAsync();
-
- // Filter payments for the current year
- var filteredPayments = payments
- .Where(p => p.CreationTime.Year == currentYear)
- .OrderByDescending(p => p.CreationTime)
- .ToList();
-
- // Use the first payment in the sorted list (most recent) if available
- if (filteredPayments.Count > 0)
- {
- var latestPayment = filteredPayments[0]; // Access the most recent payment directly
- var referenceParts = latestPayment.ReferenceNumber.Split('-');
-
- // Extract the sequence number from the reference number safely
- if (referenceParts.Length > 0 && int.TryParse(referenceParts[^1], out int latestSequenceNumber))
- {
- return latestSequenceNumber + 1;
- }
- }
- // If no payments exist or parsing fails, return the initial sequence number
- return 1;
+ public async Task> GetPaymentPendingListByCorrelationIdAsync(Guid applicationId)
+ {
+ return await paymentRequestQueryManager.GetPaymentPendingListByCorrelationIdAsync(applicationId);
}
}
}
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/ISupplierAppService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/ISupplierAppService.cs
index 9aade76e2..d173f9b2f 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/ISupplierAppService.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/ISupplierAppService.cs
@@ -1,5 +1,4 @@
using System;
-using System.Collections.Generic;
using System.Threading.Tasks;
using Volo.Abp.Application.Services;
using Unity.Payments.Enums;
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SupplierAppService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SupplierAppService.cs
index c9a2b7b7f..9b6b1bf2c 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SupplierAppService.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Suppliers/SupplierAppService.cs
@@ -1,5 +1,4 @@
using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Logging.Abstractions;
using System;
using System.Collections.Generic;
using System.Linq;
@@ -12,6 +11,7 @@
using Unity.Payments.Enums;
using Unity.Payments.Integrations.Cas;
using Volo.Abp.Features;
+using Unity.Modules.Shared.Correlation;
namespace Unity.Payments.Suppliers
{
@@ -20,27 +20,40 @@ public class SupplierAppService(ISupplierRepository supplierRepository,
ISupplierService supplierService,
ISiteAppService siteAppService,
IApplicationRepository applicationRepository) : PaymentsAppService, ISupplierAppService
- {
- protected ILogger logger => LazyServiceProvider.LazyGetService(provider => LoggerFactory?.CreateLogger(GetType().FullName!) ?? NullLogger.Instance);
-
+ {
public virtual async Task