diff --git a/.github/workflows/docker-build-dev.yml b/.github/workflows/docker-build-dev.yml
index 70596aa88..5f33222ea 100644
--- a/.github/workflows/docker-build-dev.yml
+++ b/.github/workflows/docker-build-dev.yml
@@ -10,8 +10,8 @@ on:
- '.gitignore'
- 'database/**'
- 'documentation/**'
- - 'openshift/**'
- - 'tests/**'
+ - '**/docs/**'
+ - '**/README*'
- 'CODE_OF_CONDUCT.md'
- 'COMPLIANCE.yaml'
- 'CONTRIBUTING.md'
diff --git a/.github/workflows/docker-build-main.yml b/.github/workflows/docker-build-main.yml
index f583143dc..c0294fe06 100644
--- a/.github/workflows/docker-build-main.yml
+++ b/.github/workflows/docker-build-main.yml
@@ -10,8 +10,8 @@ on:
- '.gitignore'
- 'database/**'
- 'documentation/**'
- - 'openshift/**'
- - 'tests/**'
+ - '**/docs/**'
+ - '**/README*'
- 'CODE_OF_CONDUCT.md'
- 'COMPLIANCE.yaml'
- 'CONTRIBUTING.md'
diff --git a/.github/workflows/docker-build-test.yml b/.github/workflows/docker-build-test.yml
index c061a34c6..3b7e9d91f 100644
--- a/.github/workflows/docker-build-test.yml
+++ b/.github/workflows/docker-build-test.yml
@@ -10,8 +10,8 @@ on:
- '.gitignore'
- 'database/**'
- 'documentation/**'
- - 'openshift/**'
- - 'tests/**'
+ - '**/docs/**'
+ - '**/README*'
- 'CODE_OF_CONDUCT.md'
- 'COMPLIANCE.yaml'
- 'CONTRIBUTING.md'
diff --git a/.github/workflows/pr-check-dev-branch.yml b/.github/workflows/pr-check-dev-branch.yml
index f08a1749d..aaadd9726 100644
--- a/.github/workflows/pr-check-dev-branch.yml
+++ b/.github/workflows/pr-check-dev-branch.yml
@@ -1,4 +1,4 @@
-name: Dev - CI & Unit Tests
+name: Dev - Branch Protection - CI & Unit Tests
permissions:
contents: read
diff --git a/.github/workflows/pr-check-main-branch.yml b/.github/workflows/pr-check-main-branch.yml
index 8199f1aba..205a2c9d8 100644
--- a/.github/workflows/pr-check-main-branch.yml
+++ b/.github/workflows/pr-check-main-branch.yml
@@ -1,4 +1,4 @@
-name: Main - Branch Protection
+name: Main - Branch Protection - CI & Unit Tests
permissions:
contents: read
pull-requests: write
diff --git a/.gitignore b/.gitignore
index 841973049..cf56f6bd3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,7 @@
*.rsuser
*.userosscache
*.sln.docstates
+*.code-workspace
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
diff --git a/README.md b/README.md
index 4425d1952..46240d8e9 100644
--- a/README.md
+++ b/README.md
@@ -14,9 +14,8 @@ The project is in a reliable state and major changes are unlikely to happen.
├── Unity.NginxData/ - Nginx HTTP server and reference files
├── Unity.RabbitMQ/ - RabbitMQ message broker configuration
└── Unity.RedisSentinel/- Redis Sentinel high-availability setup
- database/ - Database configuration and scripts
- documentation/ - Solution documentation and assets
- openshift/ - OpenShift deployment files and configs
+ database/ - Database configuration scripts
+ documentation/ - Solution documentation
COMPLIANCE.yaml - BCGov PIA/STRA compliance status
CONTRIBUTING.md - How to contribute
LICENSE - License
diff --git a/applications/Unity.AutoUI/cypress/e2e/ApplicationsActionBar.cy.ts b/applications/Unity.AutoUI/cypress/e2e/ApplicationsActionBar.cy.ts
index 8081e917c..7bbdde704 100644
--- a/applications/Unity.AutoUI/cypress/e2e/ApplicationsActionBar.cy.ts
+++ b/applications/Unity.AutoUI/cypress/e2e/ApplicationsActionBar.cy.ts
@@ -1,7 +1,7 @@
///
import { loginIfNeeded } from "../support/auth";
-import { ApplicationsListPage } from "../pages/ApplicationDetailsPage";
+import { ApplicationsListPage } from "../pages/ApplicationsListPage";
describe("Unity Login and check data from CHEFS", () => {
const page = new ApplicationsListPage();
@@ -171,13 +171,12 @@ describe("Unity Login and check data from CHEFS", () => {
page.switchToGrantProgram("Default Grants Program");
});
- it("Tests the existence and functionality of the Submitted Date From and Submitted Date To filters", () => {
- // Set date filters using page object methods
+ it("Tests the existence and functionality of the Quick Date Range filter", () => {
+ // Select "All time" from quick date range dropdown and verify table refreshes
page
- .setSubmittedFromDate("2022-01-01")
+ .selectQuickDateRange("alltime")
.waitForTableRefresh()
- .setSubmittedToDate(page.getTodayIsoLocal())
- .waitForTableRefresh();
+ .verifyQuickDateRangeValue("alltime");
});
// With no rows selected verify the visibility of Filter, Export, Save View, and Columns.
@@ -196,7 +195,7 @@ describe("Unity Login and check data from CHEFS", () => {
.verifyTableHasData()
.selectMultipleRows([0, 1])
.verifyActionBarExists()
- .clickPaymentButton()
+ .clickPaymentButtonWithWait()
.waitForPaymentModalVisible()
.closePaymentModal()
.verifyPaymentModalClosed();
diff --git a/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts b/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts
index 152081bae..0c08e11ce 100644
--- a/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts
+++ b/applications/Unity.AutoUI/cypress/e2e/basicEmail.cy.ts
@@ -269,6 +269,24 @@ describe("Send an email", () => {
});
it("Open Emails tab", () => {
+ // Dismiss any swal2 modal that may be covering the tab
+ cy.get("body").then(($body) => {
+ if ($body.find(".swal2-container").length > 0) {
+ cy.get(".swal2-container").then(($swal) => {
+ if ($swal.find(".swal2-close").length > 0) {
+ cy.get(".swal2-close").click({ force: true });
+ } else if ($swal.find(".swal2-confirm").length > 0) {
+ cy.get(".swal2-confirm").click({ force: true });
+ } else {
+ cy.get("body").type("{esc}", { force: true });
+ }
+ });
+ cy.get(".swal2-container", { timeout: STANDARD_TIMEOUT }).should(
+ "not.exist",
+ );
+ }
+ });
+
cy.get("#emails-tab", { timeout: STANDARD_TIMEOUT })
.should("exist")
.should("be.visible")
diff --git a/applications/Unity.AutoUI/cypress/e2e/chefsdata.cy.ts b/applications/Unity.AutoUI/cypress/e2e/chefsdata.cy.ts
index b9ec8948c..05fe3e5a5 100644
--- a/applications/Unity.AutoUI/cypress/e2e/chefsdata.cy.ts
+++ b/applications/Unity.AutoUI/cypress/e2e/chefsdata.cy.ts
@@ -59,13 +59,12 @@ describe('Unity Login and check data from CHEFS', () => {
// Ensure the search field exists
cy.get('#search', { timeout: STANDARD_TIMEOUT }).should('exist')
- // Conditionally widen Submitted Date range if the control exists
+ // Select "All time" from quick date range to widen the search
cy.get('body', { timeout: STANDARD_TIMEOUT }).then(($body) => {
- if ($body.find('input#submittedFromDate').length > 0) {
- cy.get('input#submittedFromDate', { timeout: STANDARD_TIMEOUT })
- .should('exist')
- .clear()
- .type('2022-01-01')
+ if ($body.find('select#quickDateRange').length > 0) {
+ cy.get('select#quickDateRange', { timeout: STANDARD_TIMEOUT })
+ .should('be.visible')
+ .select('alltime')
}
})
diff --git a/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts b/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts
index 34eb15827..6194b02f1 100644
--- a/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts
+++ b/applications/Unity.AutoUI/cypress/pages/ApplicationDetailsPage.ts
@@ -2,506 +2,6 @@
import { BasePage } from "./BasePage";
-/**
- * ApplicationsListPage - Page Object for the Grant Applications List page
- * Handles action bar, filters, table operations, columns menu, and modals
- */
-export class ApplicationsListPage extends BasePage {
- private readonly STANDARD_TIMEOUT = 20000;
- private readonly BUTTON_TIMEOUT = 60000;
-
- // Date filter selectors
- private readonly dateFilters = {
- submittedFromDate: "input#submittedFromDate",
- submittedToDate: "input#submittedToDate",
- spinner: 'div.spinner-grow[role="status"]',
- };
-
- // Action bar selectors
- private readonly actionBar = {
- customButtons: "#app_custom_buttons",
- dynamicButtonContainer: "#dynamicButtonContainerId",
- paymentButton: "#applicationPaymentRequest",
- exportButton: "#dynamicButtonContainerId .dt-buttons button span",
- saveViewButton: "button.grp-savedStates",
- columnsButton: "span",
- };
-
- // Table selectors
- private readonly table = {
- scrollBody: ".dt-scroll-body",
- tableRows: ".dt-scroll-body tbody tr",
- scrollHead: ".dt-scroll-head",
- columnTitles: ".dt-scroll-head span.dt-column-title",
- };
-
- // Columns menu selectors
- private readonly columnsMenu = {
- dropdownItem: "a.dropdown-item",
- buttonBackground: "div.dt-button-background",
- };
-
- // Payment modal selectors
- private readonly paymentModal = {
- modal: "#payment-modal",
- backdrop: ".modal-backdrop",
- cancelButton: "#payment-modal .modal-footer button",
- };
-
- // Grant program selectors
- private readonly grantProgram = {
- userInitials: ".unity-user-initials",
- userDropdown: "#user-dropdown a.dropdown-item",
- searchInput: "#search-grant-programs",
- programsTable: "#UserGrantProgramsTable",
- programsTableRow: "#UserGrantProgramsTable tbody tr",
- };
-
- // Save view selectors
- private readonly saveView = {
- button: "button.grp-savedStates",
- resetOption: "a.dropdown-item",
- };
-
- constructor() {
- super();
- }
-
- // ============ Date Filter Methods ============
-
- /**
- * Set the Submitted From Date filter
- */
- setSubmittedFromDate(date: string): this {
- cy.get(this.dateFilters.submittedFromDate, { timeout: this.STANDARD_TIMEOUT })
- .click({ force: true })
- .clear({ force: true })
- .type(date, { force: true })
- .trigger("change", { force: true })
- .blur({ force: true })
- .should("have.value", date);
- return this;
- }
-
- /**
- * Set the Submitted To Date filter
- */
- setSubmittedToDate(date: string): this {
- cy.get(this.dateFilters.submittedToDate, { timeout: this.STANDARD_TIMEOUT })
- .click({ force: true })
- .clear({ force: true })
- .type(date, { force: true })
- .trigger("change", { force: true })
- .blur({ force: true })
- .should("have.value", date);
- return this;
- }
-
- /**
- * Wait for table refresh (spinner to be hidden)
- */
- waitForTableRefresh(): this {
- cy.get(this.dateFilters.spinner, { timeout: this.STANDARD_TIMEOUT }).then(
- ($s: JQuery) => {
- cy.wrap($s)
- .should("have.attr", "style")
- .and("contain", "display: none");
- }
- );
- return this;
- }
-
- /**
- * Get today's date in ISO local format (YYYY-MM-DD)
- */
- getTodayIsoLocal(): string {
- const d = new Date();
- const pad2 = (n: number) => String(n).padStart(2, "0");
- return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
- }
-
- // ============ Table Methods ============
-
- /**
- * Verify table has rows
- */
- verifyTableHasData(): this {
- cy.get(this.table.tableRows, { timeout: this.STANDARD_TIMEOUT }).should(
- "have.length.greaterThan",
- 1
- );
- return this;
- }
-
- /**
- * Select a row by index (clicks on a non-link cell)
- */
- selectRowByIndex(rowIndex: number, withCtrl = false): this {
- cy.get(this.table.tableRows, { timeout: this.STANDARD_TIMEOUT })
- .eq(rowIndex)
- .find("td")
- .not(":has(a)")
- .first()
- .click({ force: true, ctrlKey: withCtrl });
- return this;
- }
-
- /**
- * Select multiple rows by indices
- */
- selectMultipleRows(indices: number[]): this {
- indices.forEach((index, i) => {
- this.selectRowByIndex(index, i > 0);
- });
- return this;
- }
-
- /**
- * Scroll table horizontally to a specific position
- */
- scrollTableHorizontally(x: number): this {
- cy.get(this.table.scrollBody, { timeout: this.STANDARD_TIMEOUT })
- .should("exist")
- .scrollTo(x, 0, { duration: 0, ensureScrollable: false });
- return this;
- }
-
- /**
- * Get visible header titles from the table
- */
- getVisibleHeaderTitles(): Cypress.Chainable {
- return cy
- .get(this.table.columnTitles, { timeout: this.STANDARD_TIMEOUT })
- .then(($els: JQuery) => {
- const titles: string[] = Cypress.$($els)
- .toArray()
- .map((el: HTMLElement) => (el.textContent || "").replace(/\s+/g, " ").trim())
- .filter((t: string) => t.length > 0);
- return titles;
- });
- }
-
- /**
- * Assert that visible headers include expected columns
- */
- assertVisibleHeadersInclude(expected: string[]): this {
- this.getVisibleHeaderTitles().then((titles: string[]) => {
- expected.forEach((e: string) => {
- expect(titles, `visible headers should include "${e}"`).to.include(e);
- });
- });
- return this;
- }
-
- // ============ Action Bar Methods ============
-
- /**
- * Scroll to and verify action bar exists
- */
- verifyActionBarExists(): this {
- cy.get(this.actionBar.customButtons, { timeout: this.STANDARD_TIMEOUT })
- .should("exist")
- .scrollIntoView();
- return this;
- }
-
- /**
- * Click the Payment button
- */
- clickPaymentButton(): this {
- cy.get(this.actionBar.paymentButton, { timeout: this.BUTTON_TIMEOUT })
- .should("be.visible")
- .and("not.be.disabled")
- .click({ force: true });
- return this;
- }
-
- /**
- * Verify Export button is visible
- */
- verifyExportButtonVisible(): this {
- cy.contains(this.actionBar.exportButton, "Export", {
- timeout: this.STANDARD_TIMEOUT,
- }).should("be.visible");
- return this;
- }
-
- /**
- * Verify Save View button is visible
- */
- verifySaveViewButtonVisible(): this {
- cy.contains(
- "#dynamicButtonContainerId button.grp-savedStates",
- "Save View",
- { timeout: this.STANDARD_TIMEOUT }
- ).should("be.visible");
- return this;
- }
-
- /**
- * Verify Columns button is visible
- */
- verifyColumnsButtonVisible(): this {
- cy.contains(
- "#dynamicButtonContainerId .dt-buttons button span",
- "Columns",
- { timeout: this.STANDARD_TIMEOUT }
- ).should("be.visible");
- return this;
- }
-
- /**
- * Verify dynamic button container exists
- */
- verifyDynamicButtonContainerExists(): this {
- cy.get(this.actionBar.dynamicButtonContainer, {
- timeout: this.STANDARD_TIMEOUT,
- })
- .should("exist")
- .scrollIntoView();
- return this;
- }
-
- // ============ Payment Modal Methods ============
-
- /**
- * Wait for payment modal to be visible
- */
- waitForPaymentModalVisible(): this {
- cy.get(this.paymentModal.modal, { timeout: this.STANDARD_TIMEOUT })
- .should("be.visible")
- .and("have.class", "show");
- return this;
- }
-
- /**
- * Close payment modal using multiple strategies
- */
- closePaymentModal(): this {
- // Attempt ESC key
- cy.get("body").type("{esc}", { force: true });
-
- // Click backdrop if present
- cy.get(this.paymentModal.backdrop, { timeout: this.STANDARD_TIMEOUT }).then(
- ($bd: JQuery) => {
- if ($bd.length) {
- cy.wrap($bd).click("topLeft", { force: true });
- }
- }
- );
-
- // Try Cancel button if available
- cy.contains(this.paymentModal.cancelButton, "Cancel", {
- timeout: this.STANDARD_TIMEOUT,
- }).then(($btn: JQuery) => {
- if ($btn && $btn.length > 0) {
- cy.wrap($btn).scrollIntoView().click({ force: true });
- } else {
- cy.log("Cancel button not present, proceeding to hard-close fallback");
- }
- });
-
- // Hard close fallback using jQuery
- cy.window().then((win: Cypress.AUTWindow) => {
- try {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const windowWithModal = win as any;
- if (typeof windowWithModal.closePaymentModal === "function") {
- windowWithModal.closePaymentModal();
- }
- } catch {
- /* ignore */
- }
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const $ = (win as any).jQuery || (win as any).$;
- if ($) {
- try {
- $("#payment-modal")
- .removeClass("show")
- .attr("aria-hidden", "true")
- .css("display", "none");
- $(".modal-backdrop").remove();
- $("body").removeClass("modal-open").css("overflow", "");
- } catch {
- /* ignore */
- }
- }
- });
- return this;
- }
-
- /**
- * Verify payment modal is closed
- */
- verifyPaymentModalClosed(): this {
- cy.get(this.paymentModal.modal, { timeout: this.STANDARD_TIMEOUT }).should(
- ($m: JQuery) => {
- const isHidden = !$m.is(":visible") || !$m.hasClass("show");
- expect(isHidden, "payment-modal hidden or not shown").to.eq(true);
- }
- );
- cy.get(this.paymentModal.backdrop, { timeout: this.STANDARD_TIMEOUT }).should(
- "not.exist"
- );
- return this;
- }
-
- // ============ Columns Menu Methods ============
-
- /**
- * Close any open dropdowns or modals
- */
- closeOpenDropdowns(): this {
- cy.get("body").then(($body: JQuery) => {
- if ($body.find(this.columnsMenu.buttonBackground).length > 0) {
- cy.get(this.columnsMenu.buttonBackground).click({ force: true });
- }
- });
- return this;
- }
-
- /**
- * Open Save View dropdown and reset to default
- */
- resetToDefaultView(): this {
- cy.get(this.saveView.button, { timeout: this.STANDARD_TIMEOUT })
- .should("be.visible")
- .and("contain.text", "Save View")
- .click();
-
- cy.contains(this.saveView.resetOption, "Reset to Default View", {
- timeout: this.STANDARD_TIMEOUT,
- })
- .should("exist")
- .click({ force: true });
-
- // Wait for table to rebuild
- cy.get(this.table.columnTitles, { timeout: this.STANDARD_TIMEOUT }).should(
- "have.length.gt",
- 5
- );
- return this;
- }
-
- /**
- * Open the Columns menu
- */
- openColumnsMenu(): this {
- cy.contains("span", "Columns", { timeout: this.STANDARD_TIMEOUT })
- .should("be.visible")
- .click();
-
- // Wait for dropdown to be fully populated
- cy.get(this.columnsMenu.dropdownItem, { timeout: this.STANDARD_TIMEOUT }).should(
- "have.length.gt",
- 50
- );
- return this;
- }
-
- /**
- * Click a column item in the Columns menu
- */
- clickColumnsItem(label: string): this {
- cy.contains(this.columnsMenu.dropdownItem, label, {
- timeout: this.STANDARD_TIMEOUT,
- })
- .should("exist")
- .scrollIntoView()
- .click({ force: true });
- return this;
- }
-
- /**
- * Toggle multiple columns (click each one)
- */
- toggleColumns(columns: string[]): this {
- columns.forEach((column) => {
- this.clickColumnsItem(column);
- });
- return this;
- }
-
- /**
- * Close the Columns menu
- */
- closeColumnsMenu(): this {
- cy.get(this.columnsMenu.buttonBackground, { timeout: this.STANDARD_TIMEOUT })
- .should("exist")
- .click({ force: true });
-
- cy.get(this.columnsMenu.buttonBackground, {
- timeout: this.STANDARD_TIMEOUT,
- }).should("not.exist");
- return this;
- }
-
- // ============ Grant Program Methods ============
-
- /**
- * Switch to a specific grant program if available
- */
- switchToGrantProgram(programName: string): this {
- cy.get("body").then(($body: JQuery) => {
- const hasUserInitials =
- $body.find(this.grantProgram.userInitials).length > 0;
-
- if (!hasUserInitials) {
- cy.log("Skipping tenant switch: no user initials menu found");
- return;
- }
-
- cy.get(this.grantProgram.userInitials).click();
-
- cy.get("body").then(($body2: JQuery) => {
- const switchLink = $body2
- .find(this.grantProgram.userDropdown)
- .filter((_: number, el: HTMLElement) => {
- return (el.textContent || "").trim() === "Switch Grant Programs";
- });
-
- if (switchLink.length === 0) {
- cy.log(
- 'Skipping tenant switch: "Switch Grant Programs" not present for this user/session'
- );
- cy.get("body").click(0, 0);
- return;
- }
-
- cy.wrap(switchLink.first()).click();
-
- cy.url({ timeout: this.STANDARD_TIMEOUT }).should(
- "include",
- "/GrantPrograms"
- );
-
- cy.get(this.grantProgram.searchInput, { timeout: this.STANDARD_TIMEOUT })
- .should("be.visible")
- .clear()
- .type(programName);
-
- cy.contains(this.grantProgram.programsTableRow, programName, {
- timeout: this.STANDARD_TIMEOUT,
- })
- .should("exist")
- .within(() => {
- cy.contains("button", "Select").should("be.enabled").click();
- });
-
- cy.location("pathname", { timeout: this.STANDARD_TIMEOUT }).should(
- (p: string) => {
- expect(
- p.indexOf("/GrantApplications") >= 0 || p.indexOf("/auth/") >= 0
- ).to.eq(true);
- }
- );
- });
- });
- return this;
- }
-}
-
/**
* ApplicationDetailsPage - Page Object for the Application Details page
* Handles tabs, status actions, and field verification
diff --git a/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts b/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts
new file mode 100644
index 000000000..aae8eb72b
--- /dev/null
+++ b/applications/Unity.AutoUI/cypress/pages/ApplicationsListPage.ts
@@ -0,0 +1,574 @@
+///
+
+import { ApplicationsPage } from "./ListPages";
+
+/**
+ * ApplicationsListPage - Extended Page Object for the Grant Applications List page
+ * Extends ApplicationsPage with additional functionality for:
+ * - Date filters
+ * - Columns menu operations
+ * - Payment modal handling
+ * - Table horizontal scrolling and column visibility
+ */
+export class ApplicationsListPage extends ApplicationsPage {
+ private readonly STANDARD_TIMEOUT = 20000;
+ private readonly BUTTON_TIMEOUT = 60000;
+
+ // Date filter selectors
+ private readonly dateFilters = {
+ quickDateRange: "select#quickDateRange",
+ submittedFromDate: "input#submittedFromDate",
+ submittedToDate: "input#submittedToDate",
+ spinner: 'div.spinner-grow[role="status"]',
+ };
+
+ // Quick date range option values
+ private readonly quickDateRangeOptions = {
+ today: "today",
+ last7Days: "last7days",
+ last30Days: "last30days",
+ last3Months: "last3months",
+ last6Months: "last6months",
+ allTime: "alltime",
+ custom: "custom",
+ };
+
+ // Extended action bar selectors (beyond ApplicationsPage)
+ private readonly extendedActionBar = {
+ customButtons: "#app_custom_buttons",
+ dynamicButtonContainer: "#dynamicButtonContainerId",
+ exportButton: "#dynamicButtonContainerId .dt-buttons button span",
+ saveViewButton: "button.grp-savedStates",
+ };
+
+ // Table scrolling selectors
+ private readonly scrollTable = {
+ scrollBody: ".dt-scroll-body",
+ tableRows: ".dt-scroll-body tbody tr",
+ scrollHead: ".dt-scroll-head",
+ columnTitles: ".dt-scroll-head span.dt-column-title",
+ };
+
+ // Columns menu selectors
+ private readonly columnsMenu = {
+ dropdownItem: "a.dropdown-item",
+ buttonBackground: "div.dt-button-background",
+ };
+
+ // Payment modal selectors
+ private readonly paymentModal = {
+ modal: "#payment-modal",
+ backdrop: ".modal-backdrop",
+ cancelButton: "#payment-modal .modal-footer button",
+ };
+
+ // Grant program selectors
+ private readonly grantProgram = {
+ userInitials: ".unity-user-initials",
+ userDropdown: "#user-dropdown a.dropdown-item",
+ searchInput: "#search-grant-programs",
+ programsTableRow: "#UserGrantProgramsTable tbody tr",
+ };
+
+ // Save view selectors
+ private readonly saveView = {
+ button: "button.grp-savedStates",
+ resetOption: "a.dropdown-item",
+ };
+
+ constructor() {
+ super();
+ }
+
+ // ============ Date Filter Methods ============
+
+ /**
+ * Select a quick date range from the dropdown
+ * @param range - One of: "today", "last7days", "last30days", "last3months", "last6months", "alltime", "custom"
+ */
+ selectQuickDateRange(
+ range:
+ | "today"
+ | "last7days"
+ | "last30days"
+ | "last3months"
+ | "last6months"
+ | "alltime"
+ | "custom"
+ ): this {
+ cy.get(this.dateFilters.quickDateRange, { timeout: this.STANDARD_TIMEOUT })
+ .should("be.visible")
+ .select(range);
+ return this;
+ }
+
+ /**
+ * Select "Last 6 months" from quick date range (default)
+ */
+ selectLast6Months(): this {
+ return this.selectQuickDateRange("last6months");
+ }
+
+ /**
+ * Select "All time" from quick date range
+ */
+ selectAllTime(): this {
+ return this.selectQuickDateRange("alltime");
+ }
+
+ /**
+ * Verify the quick date range dropdown has expected value
+ */
+ verifyQuickDateRangeValue(
+ expectedValue:
+ | "today"
+ | "last7days"
+ | "last30days"
+ | "last3months"
+ | "last6months"
+ | "alltime"
+ | "custom"
+ ): this {
+ cy.get(this.dateFilters.quickDateRange, { timeout: this.STANDARD_TIMEOUT })
+ .should("have.value", expectedValue);
+ return this;
+ }
+
+ /**
+ * Set the Submitted From Date filter (for custom date range)
+ * @deprecated Use selectQuickDateRange() instead. This method is for custom date ranges only.
+ */
+ setSubmittedFromDate(date: string): this {
+ cy.get(this.dateFilters.submittedFromDate, { timeout: this.STANDARD_TIMEOUT })
+ .click({ force: true })
+ .clear({ force: true })
+ .type(date, { force: true })
+ .trigger("change", { force: true })
+ .blur({ force: true })
+ .should("have.value", date);
+ return this;
+ }
+
+ /**
+ * Set the Submitted To Date filter
+ */
+ setSubmittedToDate(date: string): this {
+ cy.get(this.dateFilters.submittedToDate, { timeout: this.STANDARD_TIMEOUT })
+ .click({ force: true })
+ .clear({ force: true })
+ .type(date, { force: true })
+ .trigger("change", { force: true })
+ .blur({ force: true })
+ .should("have.value", date);
+ return this;
+ }
+
+ /**
+ * Wait for table refresh (spinner to be hidden)
+ */
+ waitForTableRefresh(): this {
+ cy.get(this.dateFilters.spinner, { timeout: this.STANDARD_TIMEOUT }).then(
+ ($s: JQuery) => {
+ cy.wrap($s)
+ .should("have.attr", "style")
+ .and("contain", "display: none");
+ }
+ );
+ return this;
+ }
+
+ /**
+ * Get today's date in ISO local format (YYYY-MM-DD)
+ */
+ getTodayIsoLocal(): string {
+ const d = new Date();
+ const pad2 = (n: number) => String(n).padStart(2, "0");
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
+ }
+
+ // ============ Extended Table Methods ============
+
+ /**
+ * Verify table has rows (using scroll body selector)
+ */
+ verifyTableHasData(): this {
+ cy.get(this.scrollTable.tableRows, { timeout: this.STANDARD_TIMEOUT }).should(
+ "have.length.greaterThan",
+ 1
+ );
+ return this;
+ }
+
+ /**
+ * Select a row by index (clicks on a non-link cell)
+ */
+ selectRowByIndex(rowIndex: number, withCtrl = false): this {
+ cy.get(this.scrollTable.tableRows, { timeout: this.STANDARD_TIMEOUT })
+ .eq(rowIndex)
+ .find("td")
+ .not(":has(a)")
+ .first()
+ .click({ force: true, ctrlKey: withCtrl });
+ return this;
+ }
+
+ /**
+ * Select multiple rows by indices
+ */
+ selectMultipleRows(indices: number[]): this {
+ indices.forEach((index, i) => {
+ this.selectRowByIndex(index, i > 0);
+ });
+ return this;
+ }
+
+ /**
+ * Scroll table horizontally to a specific position
+ */
+ scrollTableHorizontally(x: number): this {
+ cy.get(this.scrollTable.scrollBody, { timeout: this.STANDARD_TIMEOUT })
+ .should("exist")
+ .scrollTo(x, 0, { duration: 0, ensureScrollable: false });
+ return this;
+ }
+
+ /**
+ * Get visible header titles from the table
+ */
+ getVisibleHeaderTitles(): Cypress.Chainable {
+ return cy
+ .get(this.scrollTable.columnTitles, { timeout: this.STANDARD_TIMEOUT })
+ .then(($els: JQuery) => {
+ const titles: string[] = Cypress.$($els)
+ .toArray()
+ .map((el: HTMLElement) => (el.textContent || "").replace(/\s+/g, " ").trim())
+ .filter((t: string) => t.length > 0);
+ return titles;
+ });
+ }
+
+ /**
+ * Assert that visible headers include expected columns (case-insensitive)
+ */
+ assertVisibleHeadersInclude(expected: string[]): this {
+ this.getVisibleHeaderTitles().then((titles: string[]) => {
+ const titlesLower = titles.map((t: string) => t.toLowerCase());
+ expected.forEach((e: string) => {
+ expect(
+ titlesLower,
+ `visible headers should include "${e}"`
+ ).to.include(e.toLowerCase());
+ });
+ });
+ return this;
+ }
+
+ // ============ Extended Action Bar Methods ============
+
+ /**
+ * Scroll to and verify action bar exists
+ */
+ verifyActionBarExists(): this {
+ cy.get(this.extendedActionBar.customButtons, { timeout: this.STANDARD_TIMEOUT })
+ .should("exist")
+ .scrollIntoView();
+ return this;
+ }
+
+ /**
+ * Click the Payment button (extended with visibility checks)
+ */
+ clickPaymentButtonWithWait(): this {
+ cy.get("#applicationPaymentRequest", { timeout: this.BUTTON_TIMEOUT })
+ .should("be.visible")
+ .and("not.be.disabled")
+ .click({ force: true });
+ return this;
+ }
+
+ /**
+ * Verify Export button is visible
+ */
+ verifyExportButtonVisible(): this {
+ cy.contains(this.extendedActionBar.exportButton, "Export", {
+ timeout: this.STANDARD_TIMEOUT,
+ }).should("be.visible");
+ return this;
+ }
+
+ /**
+ * Verify Save View button is visible
+ */
+ verifySaveViewButtonVisible(): this {
+ cy.contains(
+ "#dynamicButtonContainerId button.grp-savedStates",
+ "Save View",
+ { timeout: this.STANDARD_TIMEOUT }
+ ).should("be.visible");
+ return this;
+ }
+
+ /**
+ * Verify Columns button is visible
+ */
+ verifyColumnsButtonVisible(): this {
+ cy.contains(
+ "#dynamicButtonContainerId .dt-buttons button span",
+ "Columns",
+ { timeout: this.STANDARD_TIMEOUT }
+ ).should("be.visible");
+ return this;
+ }
+
+ /**
+ * Verify dynamic button container exists
+ */
+ verifyDynamicButtonContainerExists(): this {
+ cy.get(this.extendedActionBar.dynamicButtonContainer, {
+ timeout: this.STANDARD_TIMEOUT,
+ })
+ .should("exist")
+ .scrollIntoView();
+ return this;
+ }
+
+ // ============ Payment Modal Methods ============
+
+ /**
+ * Wait for payment modal to be visible
+ */
+ waitForPaymentModalVisible(): this {
+ cy.get(this.paymentModal.modal, { timeout: this.STANDARD_TIMEOUT })
+ .should("be.visible")
+ .and("have.class", "show");
+ return this;
+ }
+
+ /**
+ * Close payment modal using multiple strategies
+ */
+ closePaymentModal(): this {
+ // Attempt ESC key
+ cy.get("body").type("{esc}", { force: true });
+
+ // Click backdrop if present (check existence first to avoid timeout)
+ cy.get("body").then(($body: JQuery) => {
+ if ($body.find(this.paymentModal.backdrop).length > 0) {
+ cy.get(this.paymentModal.backdrop).click("topLeft", { force: true });
+ }
+ });
+
+ // Try Cancel button if available (check existence first to avoid timeout)
+ cy.get("body").then(($body: JQuery) => {
+ const $cancelBtn = $body.find(this.paymentModal.cancelButton).filter(
+ (_: number, el: HTMLElement) => (el.textContent || "").includes("Cancel")
+ );
+ if ($cancelBtn.length > 0) {
+ cy.wrap($cancelBtn.first()).scrollIntoView().click({ force: true });
+ } else {
+ cy.log("Cancel button not present, proceeding to hard-close fallback");
+ }
+ });
+
+ // Hard close fallback using jQuery
+ cy.window().then((win: Cypress.AUTWindow) => {
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const windowWithModal = win as any;
+ if (typeof windowWithModal.closePaymentModal === "function") {
+ windowWithModal.closePaymentModal();
+ }
+ } catch {
+ /* ignore */
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const $ = (win as any).jQuery || (win as any).$;
+ if ($) {
+ try {
+ $("#payment-modal")
+ .removeClass("show")
+ .attr("aria-hidden", "true")
+ .css("display", "none");
+ $(".modal-backdrop").remove();
+ $("body").removeClass("modal-open").css("overflow", "");
+ } catch {
+ /* ignore */
+ }
+ }
+ });
+ return this;
+ }
+
+ /**
+ * Verify payment modal is closed
+ */
+ verifyPaymentModalClosed(): this {
+ cy.get(this.paymentModal.modal, { timeout: this.STANDARD_TIMEOUT }).should(
+ ($m: JQuery) => {
+ const isHidden = !$m.is(":visible") || !$m.hasClass("show");
+ expect(isHidden, "payment-modal hidden or not shown").to.eq(true);
+ }
+ );
+ cy.get(this.paymentModal.backdrop, { timeout: this.STANDARD_TIMEOUT }).should(
+ "not.exist"
+ );
+ return this;
+ }
+
+ // ============ Columns Menu Methods ============
+
+ /**
+ * Close any open dropdowns or modals
+ */
+ closeOpenDropdowns(): this {
+ cy.get("body").then(($body: JQuery) => {
+ if ($body.find(this.columnsMenu.buttonBackground).length > 0) {
+ cy.get(this.columnsMenu.buttonBackground).click({ force: true });
+ }
+ });
+ return this;
+ }
+
+ /**
+ * Open Save View dropdown and reset to default
+ */
+ resetToDefaultView(): this {
+ cy.get(this.saveView.button, { timeout: this.STANDARD_TIMEOUT })
+ .should("be.visible")
+ .and("contain.text", "Save View")
+ .click();
+
+ cy.contains(this.saveView.resetOption, "Reset to Default View", {
+ timeout: this.STANDARD_TIMEOUT,
+ })
+ .should("exist")
+ .click({ force: true });
+
+ // Wait for table to rebuild
+ cy.get(this.scrollTable.columnTitles, { timeout: this.STANDARD_TIMEOUT }).should(
+ "have.length.gt",
+ 5
+ );
+ return this;
+ }
+
+ /**
+ * Open the Columns menu
+ */
+ openColumnsMenu(): this {
+ cy.contains("span", "Columns", { timeout: this.STANDARD_TIMEOUT })
+ .should("be.visible")
+ .click();
+
+ // Wait for dropdown to be fully populated
+ cy.get(this.columnsMenu.dropdownItem, { timeout: this.STANDARD_TIMEOUT }).should(
+ "have.length.gt",
+ 50
+ );
+ return this;
+ }
+
+ /**
+ * Click a column item in the Columns menu (case-insensitive)
+ */
+ clickColumnsItem(label: string): this {
+ cy.contains(this.columnsMenu.dropdownItem, label, {
+ timeout: this.STANDARD_TIMEOUT,
+ matchCase: false,
+ })
+ .should("exist")
+ .scrollIntoView()
+ .click({ force: true });
+ return this;
+ }
+
+ /**
+ * Toggle multiple columns (click each one)
+ */
+ toggleColumns(columns: string[]): this {
+ columns.forEach((column) => {
+ this.clickColumnsItem(column);
+ });
+ return this;
+ }
+
+ /**
+ * Close the Columns menu
+ */
+ closeColumnsMenu(): this {
+ cy.get(this.columnsMenu.buttonBackground, { timeout: this.STANDARD_TIMEOUT })
+ .should("exist")
+ .click({ force: true });
+
+ cy.get(this.columnsMenu.buttonBackground, {
+ timeout: this.STANDARD_TIMEOUT,
+ }).should("not.exist");
+ return this;
+ }
+
+ // ============ Grant Program Methods ============
+
+ /**
+ * Switch to a specific grant program if available
+ * Note: Consider using NavigationPage.switchToTenantIfAvailable() for consistency
+ */
+ switchToGrantProgram(programName: string): this {
+ cy.get("body").then(($body: JQuery) => {
+ const hasUserInitials =
+ $body.find(this.grantProgram.userInitials).length > 0;
+
+ if (!hasUserInitials) {
+ cy.log("Skipping tenant switch: no user initials menu found");
+ return;
+ }
+
+ cy.get(this.grantProgram.userInitials).click();
+
+ cy.get("body").then(($body2: JQuery) => {
+ const switchLink = $body2
+ .find(this.grantProgram.userDropdown)
+ .filter((_: number, el: HTMLElement) => {
+ return (el.textContent || "").trim() === "Switch Grant Programs";
+ });
+
+ if (switchLink.length === 0) {
+ cy.log(
+ 'Skipping tenant switch: "Switch Grant Programs" not present for this user/session'
+ );
+ cy.get("body").click(0, 0);
+ return;
+ }
+
+ cy.wrap(switchLink.first()).click();
+
+ cy.url({ timeout: this.STANDARD_TIMEOUT }).should(
+ "include",
+ "/GrantPrograms"
+ );
+
+ cy.get(this.grantProgram.searchInput, { timeout: this.STANDARD_TIMEOUT })
+ .should("be.visible")
+ .clear()
+ .type(programName);
+
+ cy.contains(this.grantProgram.programsTableRow, programName, {
+ timeout: this.STANDARD_TIMEOUT,
+ })
+ .should("exist")
+ .within(() => {
+ cy.contains("button", "Select").should("be.enabled").click();
+ });
+
+ cy.location("pathname", { timeout: this.STANDARD_TIMEOUT }).should(
+ (p: string) => {
+ expect(
+ p.indexOf("/GrantApplications") >= 0 || p.indexOf("/auth/") >= 0
+ ).to.eq(true);
+ }
+ );
+ });
+ });
+ return this;
+ }
+}
diff --git a/applications/Unity.AutoUI/cypress/support/auth.ts b/applications/Unity.AutoUI/cypress/support/auth.ts
index 764222b8f..bd83e88c5 100644
--- a/applications/Unity.AutoUI/cypress/support/auth.ts
+++ b/applications/Unity.AutoUI/cypress/support/auth.ts
@@ -176,31 +176,31 @@ function performLogin(options: LoginOptions = {}): void {
*/
export function loginIfNeeded(
options: LoginOptions = {},
-): Cypress.Chainable {
+): void {
const username = options.username || (Cypress.env("test1username") as string);
const baseUrl = options.baseUrl || (Cypress.env("webapp.url") as string);
const sessionId = `unity-${baseUrl}-${username}`;
- return cy
- .session(
- sessionId,
- () => {
- performLogin(options);
- },
- {
- validate() {
- // Lightweight validation: check auth cookies exist without visiting
- return cy.getCookie(".AspNetCore.Cookies").then((cookie) => {
- if (!cookie) {
- throw new Error("Session expired - auth cookie missing");
- }
- });
- },
- cacheAcrossSpecs: true,
+ cy.session(
+ sessionId,
+ () => {
+ performLogin(options);
+ },
+ {
+ validate() {
+ // Lightweight validation: check auth cookies exist without visiting
+ cy.getCookie(".AspNetCore.Cookies").then((cookie) => {
+ if (!cookie) {
+ throw new Error("Session expired - auth cookie missing");
+ }
+ });
},
- )
- .then(() => {
- cy.visit(baseUrl);
- ensureGrantApplicationsPage(options.timeout || 20000);
- });
+ cacheAcrossSpecs: true,
+ },
+ );
+
+ cy.then(() => {
+ cy.visit(baseUrl);
+ ensureGrantApplicationsPage(options.timeout || 20000);
+ });
}
diff --git a/applications/Unity.AutoUI/cypress/support/e2e.ts b/applications/Unity.AutoUI/cypress/support/e2e.ts
index 29ee2545b..860eac1b2 100644
--- a/applications/Unity.AutoUI/cypress/support/e2e.ts
+++ b/applications/Unity.AutoUI/cypress/support/e2e.ts
@@ -6,4 +6,14 @@
// https://on.cypress.io/configuration
// ***********************************************************
-import '../support/commands.ts'
+import '../support/commands'
+
+// Ignore ResizeObserver loop errors - these are benign browser notifications
+// that occur when ResizeObserver callbacks don't complete in a single animation frame
+Cypress.on('uncaught:exception', (err) => {
+ if (err.message.includes('ResizeObserver loop')) {
+ return false
+ }
+ // Return true to fail the test for other errors
+ return true
+})
diff --git a/applications/Unity.AutoUI/tsconfig.json b/applications/Unity.AutoUI/tsconfig.json
index 6e47c796f..89a0c8e41 100644
--- a/applications/Unity.AutoUI/tsconfig.json
+++ b/applications/Unity.AutoUI/tsconfig.json
@@ -106,5 +106,5 @@
"skipLibCheck": true /* Skip type checking all .d.ts files. */
},
// Included or excluded files or folders... https://www.typescriptlang.org/tsconfig
- "include": ["cypress/support/**/*.ts","cypress/e2e/**/*.ts"]
+ "include": ["cypress/support/**/*.ts", "cypress/e2e/**/*.ts", "cypress/pages/**/*.ts"]
}
diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CheckboxGroupDefinitionWidgetTests.cs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CheckboxGroupDefinitionWidgetTests.cs
index 3ae0ec372..2f6a2b4c0 100644
--- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CheckboxGroupDefinitionWidgetTests.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CheckboxGroupDefinitionWidgetTests.cs
@@ -1,22 +1,23 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewComponents;
+using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using System.Text.Json;
using Unity.Flex.Web.Views.Shared.Components.CheckboxGroupDefinitionWidget;
using Unity.Flex.Worksheets.Definitions;
-using Unity.GrantManager;
using Volo.Abp.DependencyInjection;
namespace Unity.Flex.Web.Tests.Components
{
- public class CheckboxGroupDefinitionWidgetTests : GrantManagerWebTestBase
+ [Collection(ComponentTestCollection.Name)]
+ public class CheckboxGroupDefinitionWidgetTests
{
private readonly IAbpLazyServiceProvider lazyServiceProvider;
- public CheckboxGroupDefinitionWidgetTests()
+ public CheckboxGroupDefinitionWidgetTests(ComponentTestFixture fixture)
{
- lazyServiceProvider = GetRequiredService();
+ lazyServiceProvider = fixture.Services.GetRequiredService();
}
[Fact]
diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CheckboxGroupWidgetTests.cs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CheckboxGroupWidgetTests.cs
index 2dad6b56e..ce6dc3d95 100644
--- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CheckboxGroupWidgetTests.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CheckboxGroupWidgetTests.cs
@@ -1,21 +1,22 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewComponents;
+using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Unity.Flex.Web.Views.Shared.Components.CheckboxGroupWidget;
using Unity.Flex.Web.Views.Shared.Components.WorksheetInstanceWidget.ViewModels;
-using Unity.GrantManager;
using Volo.Abp.DependencyInjection;
namespace Unity.Flex.Web.Tests.Components
{
- public class CheckboxGroupWidgetTests : GrantManagerWebTestBase
+ [Collection(ComponentTestCollection.Name)]
+ public class CheckboxGroupWidgetTests
{
private readonly IAbpLazyServiceProvider lazyServiceProvider;
- public CheckboxGroupWidgetTests()
+ public CheckboxGroupWidgetTests(ComponentTestFixture fixture)
{
- lazyServiceProvider = GetRequiredService();
+ lazyServiceProvider = fixture.Services.GetRequiredService();
}
[Fact]
diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CheckboxWidgetTests.cs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CheckboxWidgetTests.cs
index d50ed7d57..6a9353f98 100644
--- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CheckboxWidgetTests.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CheckboxWidgetTests.cs
@@ -1,21 +1,22 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewComponents;
+using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Unity.Flex.Web.Views.Shared.Components.CheckboxWidget;
using Unity.Flex.Web.Views.Shared.Components.WorksheetInstanceWidget.ViewModels;
-using Unity.GrantManager;
using Volo.Abp.DependencyInjection;
namespace Unity.Flex.Web.Tests.Components
{
- public class CheckboxWidgetTests : GrantManagerWebTestBase
+ [Collection(ComponentTestCollection.Name)]
+ public class CheckboxWidgetTests
{
private readonly IAbpLazyServiceProvider lazyServiceProvider;
- public CheckboxWidgetTests()
+ public CheckboxWidgetTests(ComponentTestFixture fixture)
{
- lazyServiceProvider = GetRequiredService();
+ lazyServiceProvider = fixture.Services.GetRequiredService();
}
[Fact]
diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/ComponentTestFixture.cs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/ComponentTestFixture.cs
new file mode 100644
index 000000000..731cb41c9
--- /dev/null
+++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/ComponentTestFixture.cs
@@ -0,0 +1,45 @@
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Hosting;
+using Unity.GrantManager;
+using Unity.Modules.Shared.MessageBrokers.RabbitMQ.Interfaces;
+
+namespace Unity.Flex.Web.Tests.Components;
+
+public class ComponentTestFixture : WebApplicationFactory
+{
+ protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder)
+ {
+ builder.ConfigureServices(services =>
+ {
+ var hostedServices = services
+ .Where(d => typeof(IHostedService)
+ .IsAssignableFrom(d.ServiceType))
+ .ToList();
+
+ foreach (var descriptor in hostedServices)
+ {
+ if (descriptor.ImplementationType?.Namespace?.Contains("RabbitMQ") == true)
+ {
+ services.Remove(descriptor);
+ }
+ }
+
+if (OperatingSystem.IsWindows())
+ {
+ services.RemoveAll();
+ }
+
+ services.Replace(
+ ServiceDescriptor.Singleton()
+ );
+ });
+ }
+}
+
+[CollectionDefinition(ComponentTestCollection.Name)]
+public class ComponentTestCollection : ICollectionFixture
+{
+ public const string Name = "ComponentTests";
+}
diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CurrencyDefinitionWidgetTests.cs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CurrencyDefinitionWidgetTests.cs
index 8815b4730..54a780f2f 100644
--- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CurrencyDefinitionWidgetTests.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CurrencyDefinitionWidgetTests.cs
@@ -1,23 +1,23 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewComponents;
+using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using System.Text.Json;
using Unity.Flex.Web.Views.Shared.Components.CurrencyDefinitionWidget;
using Unity.Flex.Worksheets.Definitions;
-using Unity.GrantManager;
using Volo.Abp.DependencyInjection;
namespace Unity.Flex.Web.Tests.Components
{
- public class CurrencyDefinitionWidgetTests : GrantManagerWebTestBase
+ [Collection(ComponentTestCollection.Name)]
+ public class CurrencyDefinitionWidgetTests
{
private readonly IAbpLazyServiceProvider lazyServiceProvider;
- public CurrencyDefinitionWidgetTests()
+ public CurrencyDefinitionWidgetTests(ComponentTestFixture fixture)
{
- // Lazily resolve services needed by the ViewComponent
- lazyServiceProvider = GetRequiredService();
+ lazyServiceProvider = fixture.Services.GetRequiredService();
}
[Fact]
diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CurrencyWidgetTests.cs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CurrencyWidgetTests.cs
index d566f5dc2..8389402f3 100644
--- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CurrencyWidgetTests.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/CurrencyWidgetTests.cs
@@ -1,21 +1,22 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewComponents;
+using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Unity.Flex.Web.Views.Shared.Components.CurrencyWidget;
using Unity.Flex.Web.Views.Shared.Components.WorksheetInstanceWidget.ViewModels;
-using Unity.GrantManager;
using Volo.Abp.DependencyInjection;
namespace Unity.Flex.Web.Tests.Components
{
- public class CurrencyWidgetTests : GrantManagerWebTestBase
+ [Collection(ComponentTestCollection.Name)]
+ public class CurrencyWidgetTests
{
private readonly IAbpLazyServiceProvider lazyServiceProvider;
- public CurrencyWidgetTests()
+ public CurrencyWidgetTests(ComponentTestFixture fixture)
{
- lazyServiceProvider = GetRequiredService();
+ lazyServiceProvider = fixture.Services.GetRequiredService();
}
[Fact]
diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/DataGridDefinitionWidgetTests.cs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/DataGridDefinitionWidgetTests.cs
index 62ec7d722..35e7663b1 100644
--- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/DataGridDefinitionWidgetTests.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/DataGridDefinitionWidgetTests.cs
@@ -1,22 +1,23 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewComponents;
+using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using System.Text.Json;
using Unity.Flex.Web.Views.Shared.Components.DataGridDefinitionWidget;
using Unity.Flex.Worksheets.Definitions;
-using Unity.GrantManager;
using Volo.Abp.DependencyInjection;
namespace Unity.Flex.Web.Tests.Components
{
- public class DataGridDefinitionWidgetTests : GrantManagerWebTestBase
+ [Collection(ComponentTestCollection.Name)]
+ public class DataGridDefinitionWidgetTests
{
private readonly IAbpLazyServiceProvider lazyServiceProvider;
- public DataGridDefinitionWidgetTests()
+ public DataGridDefinitionWidgetTests(ComponentTestFixture fixture)
{
- lazyServiceProvider = GetRequiredService();
+ lazyServiceProvider = fixture.Services.GetRequiredService();
}
[Fact]
diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/DataGridWidgetTests.cs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/DataGridWidgetTests.cs
index 6e18ab44d..0fab52c5d 100644
--- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/DataGridWidgetTests.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/DataGridWidgetTests.cs
@@ -1,21 +1,22 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewComponents;
+using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Unity.Flex.Web.Views.Shared.Components.DataGridWidget;
using Unity.Flex.Web.Views.Shared.Components.WorksheetInstanceWidget.ViewModels;
-using Unity.GrantManager;
using Volo.Abp.DependencyInjection;
namespace Unity.Flex.Web.Tests.Components
{
- public class DataGridWidgetTests : GrantManagerWebTestBase
+ [Collection(ComponentTestCollection.Name)]
+ public class DataGridWidgetTests
{
private readonly IAbpLazyServiceProvider lazyServiceProvider;
- public DataGridWidgetTests()
+ public DataGridWidgetTests(ComponentTestFixture fixture)
{
- lazyServiceProvider = GetRequiredService();
+ lazyServiceProvider = fixture.Services.GetRequiredService();
}
[Fact]
diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/RadioWidgetTests.cs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/RadioWidgetTests.cs
index 67c69a018..c79d83181 100644
--- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/RadioWidgetTests.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/RadioWidgetTests.cs
@@ -1,21 +1,22 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewComponents;
+using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Unity.Flex.Web.Views.Shared.Components.RadioWidget;
using Unity.Flex.Web.Views.Shared.Components.WorksheetInstanceWidget.ViewModels;
-using Unity.GrantManager;
using Volo.Abp.DependencyInjection;
namespace Unity.Flex.Web.Tests.Components
{
- public class RadioWidgetTests : GrantManagerWebTestBase
+ [Collection(ComponentTestCollection.Name)]
+ public class RadioWidgetTests
{
private readonly IAbpLazyServiceProvider lazyServiceProvider;
- public RadioWidgetTests()
+ public RadioWidgetTests(ComponentTestFixture fixture)
{
- lazyServiceProvider = GetRequiredService();
+ lazyServiceProvider = fixture.Services.GetRequiredService();
}
[Fact]
diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/SelectListDefinitionWidgetTests.cs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/SelectListDefinitionWidgetTests.cs
index a63c2c36e..95ef22e1d 100644
--- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/SelectListDefinitionWidgetTests.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/SelectListDefinitionWidgetTests.cs
@@ -1,22 +1,23 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewComponents;
+using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using System.Text.Json;
using Unity.Flex.Web.Views.Shared.Components.SelectListDefinitionWidget;
using Unity.Flex.Worksheets.Definitions;
-using Unity.GrantManager;
using Volo.Abp.DependencyInjection;
namespace Unity.Flex.Web.Tests.Components
{
- public class SelectListDefinitionWidgetTests : GrantManagerWebTestBase
+ [Collection(ComponentTestCollection.Name)]
+ public class SelectListDefinitionWidgetTests
{
private readonly IAbpLazyServiceProvider lazyServiceProvider;
- public SelectListDefinitionWidgetTests()
+ public SelectListDefinitionWidgetTests(ComponentTestFixture fixture)
{
- lazyServiceProvider = GetRequiredService();
+ lazyServiceProvider = fixture.Services.GetRequiredService();
}
[Fact]
diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/SelectListWidgetTests.cs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/SelectListWidgetTests.cs
index 167b2d182..245fb1e37 100644
--- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/SelectListWidgetTests.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/SelectListWidgetTests.cs
@@ -1,21 +1,22 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewComponents;
+using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Unity.Flex.Web.Views.Shared.Components.SelectListWidget;
using Unity.Flex.Web.Views.Shared.Components.WorksheetInstanceWidget.ViewModels;
-using Unity.GrantManager;
using Volo.Abp.DependencyInjection;
namespace Unity.Flex.Web.Tests.Components
{
- public class SelectListWidgetTests : GrantManagerWebTestBase
+ [Collection(ComponentTestCollection.Name)]
+ public class SelectListWidgetTests
{
private readonly IAbpLazyServiceProvider lazyServiceProvider;
- public SelectListWidgetTests()
+ public SelectListWidgetTests(ComponentTestFixture fixture)
{
- lazyServiceProvider = GetRequiredService();
+ lazyServiceProvider = fixture.Services.GetRequiredService();
}
[Fact]
diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/TextAreaDefinitionWidgetTests.cs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/TextAreaDefinitionWidgetTests.cs
index 029d54586..66d091906 100644
--- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/TextAreaDefinitionWidgetTests.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/TextAreaDefinitionWidgetTests.cs
@@ -1,22 +1,23 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewComponents;
+using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using System.Text.Json;
using Unity.Flex.Web.Views.Shared.Components.TextAreaDefinitionWidget;
using Unity.Flex.Worksheets.Definitions;
-using Unity.GrantManager;
using Volo.Abp.DependencyInjection;
namespace Unity.Flex.Web.Tests.Components
{
- public class TextAreaDefinitionWidgetTests : GrantManagerWebTestBase
+ [Collection(ComponentTestCollection.Name)]
+ public class TextAreaDefinitionWidgetTests
{
private readonly IAbpLazyServiceProvider lazyServiceProvider;
- public TextAreaDefinitionWidgetTests()
+ public TextAreaDefinitionWidgetTests(ComponentTestFixture fixture)
{
- lazyServiceProvider = GetRequiredService();
+ lazyServiceProvider = fixture.Services.GetRequiredService();
}
diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/TextAreaWidgetTests.cs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/TextAreaWidgetTests.cs
index f8cbf63d7..5e1f09518 100644
--- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/TextAreaWidgetTests.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/TextAreaWidgetTests.cs
@@ -1,21 +1,22 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewComponents;
+using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Unity.Flex.Web.Views.Shared.Components.TextAreaWidget;
using Unity.Flex.Web.Views.Shared.Components.WorksheetInstanceWidget.ViewModels;
-using Unity.GrantManager;
using Volo.Abp.DependencyInjection;
namespace Unity.Flex.Web.Tests.Components
{
- public class TextAreaWidgetTests : GrantManagerWebTestBase
+ [Collection(ComponentTestCollection.Name)]
+ public class TextAreaWidgetTests
{
private readonly IAbpLazyServiceProvider lazyServiceProvider;
- public TextAreaWidgetTests()
+ public TextAreaWidgetTests(ComponentTestFixture fixture)
{
- lazyServiceProvider = GetRequiredService();
+ lazyServiceProvider = fixture.Services.GetRequiredService();
}
[Fact]
diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/TextDefinitionWidgetTests.cs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/TextDefinitionWidgetTests.cs
index 065af83a5..ea5de7302 100644
--- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/TextDefinitionWidgetTests.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/TextDefinitionWidgetTests.cs
@@ -1,22 +1,23 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewComponents;
+using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using System.Text.Json;
using Unity.Flex.Web.Views.Shared.Components.TextDefinitionWidget;
using Unity.Flex.Worksheets.Definitions;
-using Unity.GrantManager;
using Volo.Abp.DependencyInjection;
namespace Unity.Flex.Web.Tests.Components
{
- public class TextDefinitionWidgetTests : GrantManagerWebTestBase
+ [Collection(ComponentTestCollection.Name)]
+ public class TextDefinitionWidgetTests
{
private readonly IAbpLazyServiceProvider lazyServiceProvider;
- public TextDefinitionWidgetTests()
+ public TextDefinitionWidgetTests(ComponentTestFixture fixture)
{
- lazyServiceProvider = GetRequiredService();
+ lazyServiceProvider = fixture.Services.GetRequiredService();
}
[Fact]
diff --git a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/YesNoWidgetTests.cs b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/YesNoWidgetTests.cs
index 145cb3ea9..c56a19980 100644
--- a/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/YesNoWidgetTests.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Flex/test/Unity.Flex.Web.Tests/Components/YesNoWidgetTests.cs
@@ -1,21 +1,22 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewComponents;
+using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Unity.Flex.Web.Views.Shared.Components.WorksheetInstanceWidget.ViewModels;
using Unity.Flex.Web.Views.Shared.Components.YesNoWidget;
-using Unity.GrantManager;
using Volo.Abp.DependencyInjection;
namespace Unity.Flex.Web.Tests.Components
{
- public class YesNoWidgetTests : GrantManagerWebTestBase
+ [Collection(ComponentTestCollection.Name)]
+ public class YesNoWidgetTests
{
private readonly IAbpLazyServiceProvider lazyServiceProvider;
- public YesNoWidgetTests()
+ public YesNoWidgetTests(ComponentTestFixture fixture)
{
- lazyServiceProvider = GetRequiredService();
+ lazyServiceProvider = fixture.Services.GetRequiredService();
}
[Fact]
diff --git a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.js b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.js
index fbb5a29cb..ca8c59ccc 100644
--- a/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.js
+++ b/applications/Unity.GrantManager/modules/Unity.Notifications/src/Unity.Notifications.Web/Views/Settings/NotificationsSettingGroup/Default.js
@@ -285,7 +285,10 @@
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,
+ resize: true,
+ statusbar: true,
+ elementpath: false,
+ branding: false,
promotion: false,
content_css: false,
skin: false,
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/ApplicationPaymentRollupDto.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/ApplicationPaymentRollupDto.cs
new file mode 100644
index 000000000..71053bd12
--- /dev/null
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application.Contracts/PaymentRequests/ApplicationPaymentRollupDto.cs
@@ -0,0 +1,11 @@
+using System;
+
+namespace Unity.Payments.PaymentRequests;
+
+[Serializable]
+public class ApplicationPaymentRollupDto
+{
+ public Guid ApplicationId { get; set; }
+ public decimal TotalPaid { get; set; }
+ public decimal TotalPending { get; set; }
+}
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 be35d094c..7d1295eee 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
@@ -21,5 +21,7 @@ public interface IPaymentRequestAppService : IApplicationService
Task GetUserPaymentThresholdAsync();
Task ManuallyAddPaymentRequestsToReconciliationQueue(List paymentRequestIds);
Task> GetPaymentPendingListByCorrelationIdAsync(Guid applicationId);
+ Task GetApplicationPaymentRollupAsync(Guid applicationId);
+ Task> GetApplicationPaymentRollupBatchAsync(List applicationIds);
}
}
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 e00b7be5a..b6ef9d46d 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
@@ -4,12 +4,13 @@
using Unity.Payments.PaymentTags;
using Unity.Payments.Suppliers;
using Volo.Abp.Application.Dtos;
+using Volo.Abp.MultiTenancy;
namespace Unity.Payments.PaymentRequests
{
#pragma warning disable CS8618
[Serializable]
- public class PaymentRequestDto : AuditedEntityDto
+ public class PaymentRequestDto : AuditedEntityDto, IMultiTenant
{
public string InvoiceNumber { get; set; }
public decimal Amount { get; set; }
@@ -46,6 +47,8 @@ public class PaymentRequestDto : AuditedEntityDto
public DateTime? FsbNotificationSentDate { get; set; }
public string? FsbApNotified { get; set; }
+ public Guid? TenantId { 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/Domain/PaymentRequests/IPaymentRequestRepository.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/IPaymentRequestRepository.cs
index 5ae55debb..e4d488fcf 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/IPaymentRequestRepository.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Domain/PaymentRequests/IPaymentRequestRepository.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
+using Unity.Payments.PaymentRequests;
using Volo.Abp.Domain.Repositories;
namespace Unity.Payments.Domain.PaymentRequests
@@ -14,6 +15,6 @@ public interface IPaymentRequestRepository : IRepository
Task GetPaymentRequestByInvoiceNumber(string invoiceNumber);
Task> GetPaymentRequestsByFailedsStatusAsync();
Task> GetPaymentPendingListByCorrelationIdAsync(Guid correlationId);
-
+ Task> GetBatchPaymentRollupsByCorrelationIdsAsync(List correlationIds);
}
}
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
index 222dc9f83..3578156da 100644
--- 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
@@ -35,5 +35,9 @@ public interface IPaymentRequestQueryManager
// Pending Payments
Task> GetPaymentPendingListByCorrelationIdAsync(Guid applicationId);
+
+ // Payment Rollups (paid + pending aggregation)
+ Task GetApplicationPaymentRollupAsync(Guid applicationId, List childApplicationIds);
+ Task> GetApplicationPaymentRollupBatchAsync(List applicationIds, Dictionary> childApplicationIdsByParent);
}
}
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
index f5fe8e972..41aa2b516 100644
--- 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
@@ -88,8 +88,10 @@ public async Task CreatePaymentRequestDtoAsync(Guid paymentRe
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();
+ var filteredPayments = await paymentsQueryable
+ .Include(pr => pr.Site)
+ .Where(pr => applicationIds.Contains(pr.CorrelationId))
+ .ToListAsync();
return objectMapper.Map, List>(filteredPayments);
}
@@ -97,8 +99,10 @@ public async Task> GetListByApplicationIdsAsync(List> 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();
+ var filteredPayments = await paymentsQueryable
+ .Include(pr => pr.Site)
+ .Where(e => e.CorrelationId == applicationId)
+ .ToListAsync();
return objectMapper.Map, List>(filteredPayments);
}
@@ -218,5 +222,98 @@ public async Task> GetPaymentPendingListByCorrelationIdA
var payments = await paymentRequestRepository.GetPaymentPendingListByCorrelationIdAsync(applicationId);
return objectMapper.Map, List>(payments);
}
+
+ ///
+ /// Retrieves a payment rollup for the specified application and its associated child applications.
+ ///
+ /// This method combines payment information from both the main application and its child
+ /// applications, providing an overall view of payment status. Use this method to obtain a single rollup when
+ /// displaying applications with related child records.
+ /// The unique identifier of the main application for which the payment rollup is requested.
+ /// A list of unique identifiers representing child applications whose payment data will be included in the
+ /// rollup. Cannot be null.
+ /// An instance of containing the aggregated total paid and pending
+ /// amounts for the main application and its child applications.
+ public async Task GetApplicationPaymentRollupAsync(Guid applicationId, List childApplicationIds)
+ {
+ var allCorrelationIds = new List { applicationId };
+ allCorrelationIds.AddRange(childApplicationIds);
+
+ var batchRollup = await paymentRequestRepository.GetBatchPaymentRollupsByCorrelationIdsAsync(allCorrelationIds);
+
+ return new ApplicationPaymentRollupDto
+ {
+ ApplicationId = applicationId,
+ TotalPaid = batchRollup.Sum(s => s.TotalPaid),
+ TotalPending = batchRollup.Sum(s => s.TotalPending)
+ };
+ }
+
+ ///
+ /// Retrieves batch payment rollup information for the specified application IDs, aggregating totals
+ /// for both paid and pending amounts from parent and child applications.
+ ///
+ /// This method performs a single database query to efficiently aggregate payment data
+ /// for all specified parent and child applications. The results include all relevant payment information
+ /// for each application ID provided.
+ /// A list of unique identifiers for the applications whose payment rollups are to be retrieved. Cannot be
+ /// null or empty.
+ /// A dictionary that maps each parent application ID to a list of its child application IDs. Must not be null
+ /// and should contain valid GUIDs.
+ /// A dictionary where each key is an application ID and the value is an containing
+ /// the total paid and pending amounts for that application, including amounts from any child applications.
+ public async Task> GetApplicationPaymentRollupBatchAsync(
+ List applicationIds,
+ Dictionary> childApplicationIdsByParent)
+ {
+ // Collect all unique correlation IDs (parents + all children) for a single DB query
+ var allCorrelationIds = new HashSet(applicationIds);
+ foreach (var childIds in childApplicationIdsByParent.Values)
+ {
+ foreach (var childId in childIds)
+ {
+ allCorrelationIds.Add(childId);
+ }
+ }
+
+ var paymentRollups = await paymentRequestRepository.GetBatchPaymentRollupsByCorrelationIdsAsync(allCorrelationIds.ToList());
+ var rollupLookup = paymentRollups.ToDictionary(s => s.ApplicationId);
+
+ var result = new Dictionary();
+ foreach (var applicationId in applicationIds)
+ {
+ decimal totalPaid = 0;
+ decimal totalPending = 0;
+
+ // Add the parent application's own amounts
+ if (rollupLookup.TryGetValue(applicationId, out var parentRollup))
+ {
+ totalPaid += parentRollup.TotalPaid;
+ totalPending += parentRollup.TotalPending;
+ }
+
+ // Add child application amounts
+ if (childApplicationIdsByParent.TryGetValue(applicationId, out var childIds))
+ {
+ foreach (var childId in childIds)
+ {
+ if (rollupLookup.TryGetValue(childId, out var childApplicationRollup))
+ {
+ totalPaid += childApplicationRollup.TotalPaid;
+ totalPending += childApplicationRollup.TotalPending;
+ }
+ }
+ }
+
+ result[applicationId] = new ApplicationPaymentRollupDto
+ {
+ ApplicationId = applicationId,
+ TotalPaid = totalPaid,
+ TotalPending = totalPending
+ };
+ }
+
+ return result;
+ }
}
}
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs
index acb7a4a8b..95ecbb5c3 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/EntityFrameworkCore/Repositories/PaymentRequestRepository.cs
@@ -1,4 +1,5 @@
-using System;
+using Microsoft.EntityFrameworkCore;
+using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
@@ -6,6 +7,7 @@
using Unity.Payments.Domain.PaymentRequests;
using Unity.Payments.EntityFrameworkCore;
using Unity.Payments.Enums;
+using Unity.Payments.PaymentRequests;
using Volo.Abp.Domain.Repositories.EntityFrameworkCore;
using Volo.Abp.EntityFrameworkCore;
@@ -16,9 +18,6 @@ public class PaymentRequestRepository : EfCoreRepository ReCheckStatusList { get; set; } = new List();
private List FailedStatusList { get; set; } = new List();
-
-
-
public PaymentRequestRepository(IDbContextProvider dbContextProvider) : base(dbContextProvider)
{
ReCheckStatusList.Add(CasPaymentRequestStatus.ServiceUnavailable);
@@ -32,25 +31,27 @@ public PaymentRequestRepository(IDbContextProvider dbContextP
public async Task GetCountByCorrelationId(Guid correlationId)
{
var dbSet = await GetDbSetAsync();
- return dbSet.Count(s => s.CorrelationId == correlationId);
+ return await dbSet.CountAsync(s => s.CorrelationId == correlationId);
}
public async Task GetPaymentRequestCountBySiteId(Guid siteId)
{
var dbSet = await GetDbSetAsync();
- return dbSet.Where(s => s.SiteId == siteId).Count();
- }
+ return await dbSet.Where(s => s.SiteId == siteId)
+ .CountAsync();
+ }
public async Task GetPaymentRequestByInvoiceNumber(string invoiceNumber)
{
var dbSet = await GetDbSetAsync();
- return dbSet.Where(s => s.InvoiceNumber == invoiceNumber).FirstOrDefault();
+ return await dbSet.Where(s => s.InvoiceNumber == invoiceNumber)
+ .FirstOrDefaultAsync();
}
public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync(Guid correlationId)
{
var dbSet = await GetDbSetAsync();
- decimal applicationPaymentRequestsTotal = dbSet
+ decimal applicationPaymentRequestsTotal = await dbSet
.Where(p => p.CorrelationId.Equals(correlationId))
.Where(p => p.Status != PaymentRequestStatus.L1Declined
&& p.Status != PaymentRequestStatus.L2Declined
@@ -59,7 +60,7 @@ public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync(Guid
&& p.InvoiceStatus != CasPaymentRequestStatus.ErrorFromCas)
.GroupBy(p => p.CorrelationId)
.Select(p => p.Sum(q => q.Amount))
- .FirstOrDefault();
+ .FirstOrDefaultAsync();
return applicationPaymentRequestsTotal;
}
@@ -67,15 +68,17 @@ public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync(Guid
public async Task> GetPaymentRequestsBySentToCasStatusAsync()
{
var dbSet = await GetDbSetAsync();
- return dbSet.Where(p => p.InvoiceStatus != null && ReCheckStatusList.Contains(p.InvoiceStatus)).IncludeDetails().ToList();
+ return await dbSet.Where(p => p.InvoiceStatus != null && ReCheckStatusList.Contains(p.InvoiceStatus))
+ .IncludeDetails()
+ .ToListAsync();
}
public async Task> GetPaymentRequestsByFailedsStatusAsync()
{
var dbSet = await GetDbSetAsync();
- return dbSet.Where(p => p.InvoiceStatus != null
- && FailedStatusList.Contains(p.InvoiceStatus)
- && p.LastModificationTime >= DateTime.Now.AddDays(-2)).IncludeDetails().ToList();
+ return await dbSet.Where(p => p.InvoiceStatus != null
+ && FailedStatusList.Contains(p.InvoiceStatus)
+ && p.LastModificationTime >= DateTime.Now.AddDays(-2)).IncludeDetails().ToListAsync();
}
public override async Task> WithDetailsAsync()
@@ -87,8 +90,52 @@ public override async Task> WithDetailsAsync()
public async Task> GetPaymentPendingListByCorrelationIdAsync(Guid correlationId)
{
var dbSet = await GetDbSetAsync();
- return dbSet.Where(p => p.CorrelationId.Equals(correlationId))
- .Where(p => p.Status == PaymentRequestStatus.L1Pending || p.Status == PaymentRequestStatus.L2Pending).IncludeDetails().ToList();
+ return await dbSet.Where(p => p.CorrelationId.Equals(correlationId))
+ .Where(p => p.Status == PaymentRequestStatus.L1Pending || p.Status == PaymentRequestStatus.L2Pending)
+ .IncludeDetails()
+ .ToListAsync();
+ }
+
+ ///
+ /// Asynchronously retrieves payment rollup information for each specified correlation ID.
+ ///
+ /// This method queries the database for payment records associated with the provided
+ /// correlation IDs and aggregates payment amounts based on their status. Ensure that the correlation IDs are
+ /// valid to avoid empty results.
+ /// A list of correlation IDs used to filter payment records. Each ID must be a valid GUID.
+ /// A task that represents the asynchronous operation. The task result contains a list of
+ /// ApplicationPaymentRollupDto objects, each summarizing the total paid and total pending amounts for the
+ /// corresponding correlation ID.
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance",
+ "CA1862:Use the 'StringComparison' method overloads to perform case-insensitive string comparisons",
+ Justification = "EF Core does not support StringComparison - https://github.com/dotnet/efcore/issues/1222")]
+ public async Task> GetBatchPaymentRollupsByCorrelationIdsAsync(List correlationIds)
+ {
+ var dbSet = await GetDbSetAsync();
+
+ var results = await dbSet
+ .Where(p => correlationIds.Contains(p.CorrelationId))
+ .GroupBy(p => p.CorrelationId)
+ .Select(g => new ApplicationPaymentRollupDto
+ {
+ ApplicationId = g.Key,
+ TotalPaid = g
+ .Where(p => p.PaymentStatus != null
+ && p.PaymentStatus.Trim().ToUpper() == CasPaymentRequestStatus.FullyPaid.ToUpper())
+ .Sum(p => p.Amount),
+ TotalPending = g
+ .Where(p => p.Status == PaymentRequestStatus.L1Pending
+ || p.Status == PaymentRequestStatus.L2Pending
+ || p.Status == PaymentRequestStatus.L3Pending
+ || (p.Status == PaymentRequestStatus.Submitted
+ && string.IsNullOrEmpty(p.PaymentStatus)
+ && (string.IsNullOrEmpty(p.InvoiceStatus)
+ || !p.InvoiceStatus.Contains(CasPaymentRequestStatus.ErrorFromCas))))
+ .Sum(p => p.Amount)
+ })
+ .ToListAsync();
+
+ return results;
}
}
}
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/CasTokenService.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/CasTokenService.cs
index 225fb33fd..6f0a838ea 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/CasTokenService.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/Cas/CasTokenService.cs
@@ -11,6 +11,7 @@
using Unity.GrantManager.Integrations.Css;
using Volo.Abp.DependencyInjection;
using Volo.Abp.TenantManagement;
+using Microsoft.Extensions.Logging;
namespace Unity.Payments.Integrations.Cas
{
[RemoteService(false)]
@@ -35,11 +36,11 @@ public async Task GetAuthTokenAsync(Guid tenantId)
var tenant = await tenantRepository.GetAsync(tenantId);
var casClientCode = tenant.ExtraProperties?["CasClientCode"]?.ToString();
-
if (string.IsNullOrEmpty(casClientCode))
{
throw new UserFriendlyException("No CAS client code configured for the current tenant. Please contact your administrator.");
}
+ Logger.LogInformation("Retrieved CAS client code {CasClientCode} for tenant {TenantId}", casClientCode, tenantId);
var casClientId = await casClientCodeLookupService.GetClientIdByCasClientCodeAsync(casClientCode)
?? throw new UserFriendlyException($"No CAS client configuration found for CAS client code: {casClientCode}");
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 148749505..57b08ccd9 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
@@ -163,9 +163,10 @@ public async Task GetCasInvoiceAsync(string invoiceNumbe
}
}
- public async Task GetCasPaymentAsync(string invoiceNumber, string supplierNumber, string siteNumber)
+ public async Task GetCasPaymentAsync(Guid tenantId, string invoiceNumber, string supplierNumber, string siteNumber)
{
- var authToken = await iTokenService.GetAuthTokenAsync(CurrentTenant.Id ?? Guid.Empty);
+ Logger.LogInformation("GetCasPaymentAsync for Invoice: {InvoiceNumber}, SupplierNumber: {SupplierNumber}, SiteNumber: {SiteNumber}, TenantId: {TenantId}", invoiceNumber, supplierNumber, siteNumber, tenantId);
+ var authToken = await iTokenService.GetAuthTokenAsync(tenantId);
var casBaseUrl = await endpointManagementAppService.GetUgmUrlByKeyNameAsync(DynamicUrlKeyNames.PAYMENT_API_BASE);
var resource = $"{casBaseUrl}/{CFS_APINVOICE}/{invoiceNumber}/{supplierNumber}/{siteNumber}";
var response = await resilientHttpRequest.HttpAsync(HttpMethod.Get, resource, body: null, authToken);
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/ReconciliationConsumer.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/ReconciliationConsumer.cs
index 5ee1bed7b..c462ae9b3 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/ReconciliationConsumer.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/Integrations/RabbitMQ/ReconciliationConsumer.cs
@@ -4,31 +4,36 @@
using System;
using Unity.Payments.PaymentRequests;
using Unity.Payments.Integrations.Cas;
+using Volo.Abp.MultiTenancy;
namespace Unity.Payments.Integrations.RabbitMQ;
public class ReconciliationConsumer(
CasPaymentRequestCoordinator casPaymentRequestCoordinator,
- InvoiceService invoiceService
+ InvoiceService invoiceService,
+ ICurrentTenant currentTenant
) : IQueueConsumer
{
public async Task ConsumeAsync(ReconcilePaymentMessages reconcilePaymentMessage)
{
if (reconcilePaymentMessage != null && !reconcilePaymentMessage.InvoiceNumber.IsNullOrEmpty() && reconcilePaymentMessage.TenantId != Guid.Empty)
- {
+ {
- // string invoiceNumber, string supplierNumber, string siteNumber)
- // Go to CAS retrieve the status of the payment
- CasPaymentSearchResult result = await invoiceService.GetCasPaymentAsync(
- reconcilePaymentMessage.InvoiceNumber,
- reconcilePaymentMessage.SupplierNumber,
- reconcilePaymentMessage.SiteNumber);
-
- if (result != null && result.InvoiceStatus != null && result.InvoiceStatus != "")
+ using (currentTenant.Change(reconcilePaymentMessage.TenantId))
{
- await casPaymentRequestCoordinator.UpdatePaymentRequestStatus(reconcilePaymentMessage.TenantId, reconcilePaymentMessage.PaymentRequestId, result);
- }
+ // string invoiceNumber, string supplierNumber, string siteNumber)
+ // Go to CAS retrieve the status of the payment
+ CasPaymentSearchResult result = await invoiceService.GetCasPaymentAsync(
+ reconcilePaymentMessage.TenantId,
+ reconcilePaymentMessage.InvoiceNumber,
+ reconcilePaymentMessage.SupplierNumber,
+ reconcilePaymentMessage.SiteNumber);
+ if (result != null && result.InvoiceStatus != null && result.InvoiceStatus != "")
+ {
+ await casPaymentRequestCoordinator.UpdatePaymentRequestStatus(reconcilePaymentMessage.TenantId, reconcilePaymentMessage.PaymentRequestId, result);
+ }
+ }
}
}
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs
index e908d215f..84b8cc8fa 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Application/PaymentRequests/CasPaymentRequestCoordinator.cs
@@ -92,7 +92,7 @@ public async Task ManuallyAddPaymentRequestsToReconciliationQueue(List applicationLinksService) : PaymentsAppService, IPaymentRequestAppService
{
public async Task GetDefaultAccountCodingId()
@@ -328,5 +330,38 @@ public async Task> GetPaymentPendingListByCorrelationIdA
{
return await paymentRequestQueryManager.GetPaymentPendingListByCorrelationIdAsync(applicationId);
}
+
+ ///
+ /// Retrieves the payment rollup for the specified application, including any linked child
+ /// applications.
+ ///
+ /// This method first obtains any child applications associated with the given
+ /// application ID and then queries the payment rollup for both the application and its children. Use this
+ /// method to obtain a comprehensive payment overview when applications may be linked together.
+ /// The unique identifier of the application for which the payment rollup is requested.
+ /// An instance of containing the payment rollup details for the
+ /// specified application and its linked child applications.
+ public async Task GetApplicationPaymentRollupAsync(Guid applicationId)
+ {
+ var childLinks = await applicationLinksService.Value.GetChildApplications(applicationId);
+ var childApplicationIds = childLinks.Select(l => l.LinkedApplicationId).ToList();
+ return await paymentRequestQueryManager.GetApplicationPaymentRollupAsync(applicationId, childApplicationIds);
+ }
+
+ ///
+ /// Retrieves batch payment rollup information for the specified applications and their child applications.
+ ///
+ /// This method asynchronously resolves child applications linked to the provided
+ /// application identifiers before fetching a batch of payment rollups. Ensure that all application identifiers are valid
+ /// and exist in the system.
+ /// A list of application identifiers for which payment rollups are requested. This parameter cannot be null
+ /// or empty.
+ /// A dictionary mapping each application identifier to its corresponding . The dictionary
+ /// includes entries for both the specified applications and their child applications.
+ public async Task> GetApplicationPaymentRollupBatchAsync(List applicationIds)
+ {
+ var childApplicationIdsByParent = await applicationLinksService.Value.GetChildApplicationIdsByParentIdsAsync(applicationIds);
+ return await paymentRequestQueryManager.GetApplicationPaymentRollupBatchAsync(applicationIds, childApplicationIdsByParent);
+ }
}
}
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js
index 5b7aff6ba..945406074 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Pages/PaymentRequests/Index.js
@@ -486,9 +486,11 @@ $(function () {
title: l('ApplicationPaymentListTable:RequestedOn'),
name: 'requestedOn',
data: 'creationTime',
- className: 'data-table-header',
+ className: 'data-table-header text-nowrap',
index: columnIndex,
- render: DataTable.render.date('YYYY-MM-DD', abp.localization.currentCulture.name)
+ render: function (data, type) {
+ return DateUtils.formatUtcDateToLocal(data, type);
+ }
};
}
function getUpdatedOnColumn(columnIndex) {
@@ -496,9 +498,11 @@ $(function () {
title: l('ApplicationPaymentListTable:UpdatedOn'),
name: 'updatedOn',
data: 'lastModificationTime',
- className: 'data-table-header',
+ className: 'data-table-header text-nowrap',
index: columnIndex,
- render: DataTable.render.date('YYYY-MM-DD', abp.localization.currentCulture.name)
+ render: function(data, type) {
+ return DateUtils.formatUtcDateToLocal(data, type);
+ }
};
}
function getPaidOnColumn(columnIndex) {
@@ -506,7 +510,7 @@ $(function () {
title: l('ApplicationPaymentListTable:PaidOn'),
name: 'paidOn',
data: 'paymentDate',
- className: 'data-table-header',
+ className: 'data-table-header text-nowrap',
index: columnIndex,
render: function (data) {
if (!data) return null;
@@ -575,11 +579,12 @@ $(function () {
title: l(`ApplicationPaymentListTable:L${level}ApprovalDate`),
name: `l${level}ApprovalDate`,
data: 'expenseApprovals',
- className: 'data-table-header',
+ className: 'data-table-header text-nowrap',
index: columnIndex,
- render: function (data) {
+ render: function (data, type) {
let approval = getExpenseApprovalsDetails(data, level);
- return formatDate(approval?.decisionDate);
+ const approvalDate = approval?.decisionDate;
+ return DateUtils.formatUtcDateToLocal(approvalDate, type);
}
};
}
@@ -711,12 +716,6 @@ $(function () {
return expenseApprovals.find(x => x.type == type);
}
- function formatDate(data) {
- return data != null ? luxon.DateTime.fromISO(data, {
- locale: abp.localization.currentCulture.name,
- }).toUTC().toLocaleString() : null;
- }
-
$('#search').on('input', function () {
let table = $('#PaymentRequestListTable').DataTable();
table.search($(this).val()).draw();
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js
index 56cd6f0cb..999b15e07 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/Default.js
@@ -336,8 +336,8 @@
data: 'creationTime',
className: 'data-table-header',
index: 5,
- render: function (data) {
- return formatDate(data);
+ render: function(data, type) {
+ return DateUtils.formatUtcDateToLocal(data, type);
},
};
}
@@ -349,8 +349,8 @@
data: 'lastModificationTime',
className: 'data-table-header',
index: 6,
- render: function (data) {
- return formatDate(data);
+ render: function(data, type) {
+ return DateUtils.formatUtcDateToLocal(data, type);
},
};
}
@@ -362,8 +362,8 @@
data: 'paidOn',
className: 'data-table-header',
index: 7,
- render: function (data) {
- return formatDate(data);
+ render: function (data, type) {
+ return DateUtils.formatUtcDateToLocal(data, type);
},
};
}
@@ -470,16 +470,6 @@
};
}
- function formatDate(data) {
- return data != null
- ? luxon.DateTime.fromISO(data, {
- locale: abp.localization.currentCulture.name,
- })
- .toUTC()
- .toLocaleString()
- : '{Not Available}';
- }
-
PubSub.subscribe('refresh_application_list', (msg, data) => {
dataTable.ajax.reload(null, false);
PubSub.publish('clear_payment_application');
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/PaymentInfoViewComponent.cs b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/PaymentInfoViewComponent.cs
index c3b195e30..32ddf14fe 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/PaymentInfoViewComponent.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/src/Unity.Payments.Web/Views/Shared/Components/PaymentInfo/PaymentInfoViewComponent.cs
@@ -1,11 +1,8 @@
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
-using System.Linq;
using System.Threading.Tasks;
-using Unity.GrantManager.Applications;
using Unity.GrantManager.GrantApplications;
-using Unity.Payments.Enums;
using Unity.Payments.PaymentRequests;
using Volo.Abp.AspNetCore.Mvc;
using Volo.Abp.AspNetCore.Mvc.UI.Bundling;
@@ -24,17 +21,14 @@ public class PaymentInfoViewComponent : AbpViewComponent
private readonly IGrantApplicationAppService _grantApplicationAppService;
private readonly IPaymentRequestAppService _paymentRequestService;
private readonly IFeatureChecker _featureChecker;
- private readonly IApplicationLinksService _applicationLinksService;
public PaymentInfoViewComponent(IGrantApplicationAppService grantApplicationAppService,
IPaymentRequestAppService paymentRequestService,
- IFeatureChecker featureChecker,
- IApplicationLinksService applicationLinksService)
+ IFeatureChecker featureChecker)
{
_grantApplicationAppService = grantApplicationAppService;
_paymentRequestService = paymentRequestService;
_featureChecker = featureChecker;
- _applicationLinksService = applicationLinksService;
}
public async Task InvokeAsync(Guid applicationId, Guid applicationFormVersionId)
@@ -51,42 +45,10 @@ public async Task InvokeAsync(Guid applicationId, Guid app
ApplicationFormVersionId = applicationFormVersionId,
ApplicantId = application.Applicant.Id
};
-
- var paymentRequests = await _paymentRequestService.GetListByApplicationIdAsync(applicationId);
-
- // Calculate Total Paid and Total Pending Amounts for current application
- var (paidAmount, pendingAmount) = CalculatePaymentAmounts(paymentRequests);
- model.TotalPaid = paidAmount;
- model.TotalPendingAmounts = pendingAmount;
-
- // Add Total Paid and Total Pending Amounts from child applications
- var applicationLinks = await _applicationLinksService.GetListByApplicationAsync(applicationId);
- var childApplications = applicationLinks
- .Where(link => link.LinkType == ApplicationLinkType.Child
- && link.ApplicationId != applicationId) // Exclude self-references
- .ToList();
-
- // Batch fetch payment requests for all child applications to avoid N+1 queries
- var childApplicationIds = childApplications.Select(ca => ca.ApplicationId).ToList();
- if (childApplicationIds.Count != 0)
- {
- var childPaymentRequests = await _paymentRequestService.GetListByApplicationIdsAsync(childApplicationIds);
- var paymentRequestsByAppId = childPaymentRequests
- .GroupBy(pr => pr.CorrelationId)
- .ToDictionary(g => g.Key, g => g.ToList());
-
- foreach (var childApp in childApplications)
- {
- if (paymentRequestsByAppId.TryGetValue(childApp.ApplicationId, out var requests))
- {
- // Add child's Total Paid and Total Pending Amounts
- var (childPaidAmount, childPendingAmount) = CalculatePaymentAmounts(requests);
- model.TotalPaid += childPaidAmount;
- model.TotalPendingAmounts += childPendingAmount;
- }
- }
- }
+ var rollup = await _paymentRequestService.GetApplicationPaymentRollupAsync(applicationId);
+ model.TotalPaid = rollup.TotalPaid;
+ model.TotalPendingAmounts = rollup.TotalPending;
model.RemainingAmount = application.ApprovedAmount - model.TotalPaid;
return View(model);
@@ -95,27 +57,6 @@ public async Task InvokeAsync(Guid applicationId, Guid app
return View(new PaymentInfoViewModel());
}
- private static (decimal paidAmount, decimal pendingAmount) CalculatePaymentAmounts(List paymentRequests)
- {
- var requestsList = paymentRequests;
-
- var paidAmount = requestsList
- .Where(e => !string.IsNullOrWhiteSpace(e.PaymentStatus)
- && e.PaymentStatus.Trim().Equals("Fully Paid", StringComparison.OrdinalIgnoreCase))
- .Sum(e => e.Amount);
-
- var pendingAmount = requestsList
- .Where(e => e.Status == PaymentRequestStatus.L1Pending
- || e.Status == PaymentRequestStatus.L2Pending
- || e.Status == PaymentRequestStatus.L3Pending
- || (e.Status == PaymentRequestStatus.Submitted
- && string.IsNullOrWhiteSpace(e.PaymentStatus)
- && (string.IsNullOrWhiteSpace(e.InvoiceStatus) || !e.InvoiceStatus.Trim().Contains("Error", StringComparison.OrdinalIgnoreCase))))
- .Sum(e => e.Amount);
-
- return (paidAmount, pendingAmount);
- }
-
public class PaymentInfoStyleBundleContributor : BundleContributor
{
public override void ConfigureBundle(BundleConfigurationContext context)
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestQueryManager_PaymentRollup_Tests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestQueryManager_PaymentRollup_Tests.cs
new file mode 100644
index 000000000..49c2c4bd1
--- /dev/null
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestQueryManager_PaymentRollup_Tests.cs
@@ -0,0 +1,412 @@
+using NSubstitute;
+using Shouldly;
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Linq;
+using System.Threading.Tasks;
+using Unity.Payments.Domain.Services;
+using Unity.Payments.Domain.Suppliers;
+using Unity.Payments.PaymentRequests;
+using Volo.Abp.Users;
+using Xunit;
+
+namespace Unity.Payments.Domain.PaymentRequests;
+
+[Category("Domain")]
+public class PaymentRequestQueryManager_PaymentRollup_Tests
+{
+ #region GetApplicationPaymentRollupAsync (Single Application)
+
+ [Fact]
+ public async Task Should_Return_Rollup_For_Single_Application_With_NoChildren()
+ {
+ // Arrange
+ var appId = Guid.NewGuid();
+ var repo = Substitute.For();
+ repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>())
+ .Returns(new List
+ {
+ new() { ApplicationId = appId, TotalPaid = 1500m, TotalPending = 2000m }
+ });
+
+ var manager = CreateManager(repo);
+
+ // Act
+ var result = await manager.GetApplicationPaymentRollupAsync(appId, []);
+
+ // Assert
+ result.ShouldNotBeNull();
+ result.ApplicationId.ShouldBe(appId);
+ result.TotalPaid.ShouldBe(1500m);
+ result.TotalPending.ShouldBe(2000m);
+
+ // Verify repo was called with only the parent ID
+ await repo.Received(1).GetBatchPaymentRollupsByCorrelationIdsAsync(
+ Arg.Is>(ids => ids.Count == 1 && ids.Contains(appId)));
+ }
+
+ [Fact]
+ public async Task Should_Aggregate_Rollup_From_Parent_And_Children()
+ {
+ // Arrange
+ var parentId = Guid.NewGuid();
+ var child1Id = Guid.NewGuid();
+ var child2Id = Guid.NewGuid();
+
+ var repo = Substitute.For();
+ repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>())
+ .Returns(new List
+ {
+ new() { ApplicationId = parentId, TotalPaid = 1000m, TotalPending = 500m },
+ new() { ApplicationId = child1Id, TotalPaid = 300m, TotalPending = 200m },
+ new() { ApplicationId = child2Id, TotalPaid = 700m, TotalPending = 100m }
+ });
+
+ var manager = CreateManager(repo);
+
+ // Act
+ var result = await manager.GetApplicationPaymentRollupAsync(parentId, [child1Id, child2Id]);
+
+ // Assert
+ result.ShouldNotBeNull();
+ result.ApplicationId.ShouldBe(parentId);
+ result.TotalPaid.ShouldBe(2000m); // 1000 + 300 + 700
+ result.TotalPending.ShouldBe(800m); // 500 + 200 + 100
+
+ // Verify all IDs were sent to the repository
+ await repo.Received(1).GetBatchPaymentRollupsByCorrelationIdsAsync(
+ Arg.Is>(ids => ids.Count == 3
+ && ids.Contains(parentId) && ids.Contains(child1Id) && ids.Contains(child2Id)));
+ }
+
+ [Fact]
+ public async Task Should_Return_Zeros_When_No_Payment_Data_Exists()
+ {
+ // Arrange
+ var appId = Guid.NewGuid();
+ var repo = Substitute.For();
+ repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>())
+ .Returns(new List());
+
+ var manager = CreateManager(repo);
+
+ // Act
+ var result = await manager.GetApplicationPaymentRollupAsync(appId, []);
+
+ // Assert
+ result.ShouldNotBeNull();
+ result.ApplicationId.ShouldBe(appId);
+ result.TotalPaid.ShouldBe(0m);
+ result.TotalPending.ShouldBe(0m);
+ }
+
+ [Fact]
+ public async Task Should_Return_Zeros_When_Children_Have_No_Payments()
+ {
+ // Arrange
+ var parentId = Guid.NewGuid();
+ var childId = Guid.NewGuid();
+
+ var repo = Substitute.For();
+ repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>())
+ .Returns(new List
+ {
+ new() { ApplicationId = parentId, TotalPaid = 500m, TotalPending = 0m }
+ // childId has no payments - not returned by repository
+ });
+
+ var manager = CreateManager(repo);
+
+ // Act
+ var result = await manager.GetApplicationPaymentRollupAsync(parentId, [childId]);
+
+ // Assert
+ result.TotalPaid.ShouldBe(500m); // Only parent amount
+ result.TotalPending.ShouldBe(0m);
+ }
+
+ [Fact]
+ public async Task Should_Handle_Single_Child_Application()
+ {
+ // Arrange
+ var parentId = Guid.NewGuid();
+ var childId = Guid.NewGuid();
+
+ var repo = Substitute.For();
+ repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>())
+ .Returns(new List
+ {
+ new() { ApplicationId = parentId, TotalPaid = 1000m, TotalPending = 0m },
+ new() { ApplicationId = childId, TotalPaid = 500m, TotalPending = 300m }
+ });
+
+ var manager = CreateManager(repo);
+
+ // Act
+ var result = await manager.GetApplicationPaymentRollupAsync(parentId, [childId]);
+
+ // Assert
+ result.ApplicationId.ShouldBe(parentId);
+ result.TotalPaid.ShouldBe(1500m); // 1000 + 500
+ result.TotalPending.ShouldBe(300m); // 0 + 300
+ }
+
+ #endregion
+
+ #region GetApplicationPaymentRollupsAsync (Batch)
+
+ [Fact]
+ public async Task Should_Return_Batch_Rollups_For_Multiple_Applications_Without_Children()
+ {
+ // Arrange
+ var app1Id = Guid.NewGuid();
+ var app2Id = Guid.NewGuid();
+ var app3Id = Guid.NewGuid();
+
+ var repo = Substitute.For();
+ repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>())
+ .Returns(new List
+ {
+ new() { ApplicationId = app1Id, TotalPaid = 1000m, TotalPending = 200m },
+ new() { ApplicationId = app2Id, TotalPaid = 500m, TotalPending = 100m },
+ new() { ApplicationId = app3Id, TotalPaid = 0m, TotalPending = 3000m }
+ });
+
+ var manager = CreateManager(repo);
+
+ // Act
+ var result = await manager.GetApplicationPaymentRollupBatchAsync(
+ [app1Id, app2Id, app3Id],
+ new Dictionary>());
+
+ // Assert
+ result.Count.ShouldBe(3);
+ result[app1Id].TotalPaid.ShouldBe(1000m);
+ result[app1Id].TotalPending.ShouldBe(200m);
+ result[app2Id].TotalPaid.ShouldBe(500m);
+ result[app2Id].TotalPending.ShouldBe(100m);
+ result[app3Id].TotalPaid.ShouldBe(0m);
+ result[app3Id].TotalPending.ShouldBe(3000m);
+ }
+
+ [Fact]
+ public async Task Should_Aggregate_Child_Amounts_In_Batch_Rollup()
+ {
+ // Arrange
+ var parentAId = Guid.NewGuid();
+ var parentBId = Guid.NewGuid();
+ var childA1Id = Guid.NewGuid();
+ var childA2Id = Guid.NewGuid();
+ var childB1Id = Guid.NewGuid();
+
+ var childMap = new Dictionary>
+ {
+ { parentAId, [childA1Id, childA2Id] },
+ { parentBId, [childB1Id] }
+ };
+
+ var repo = Substitute.For();
+ repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>())
+ .Returns(new List
+ {
+ new() { ApplicationId = parentAId, TotalPaid = 1000m, TotalPending = 100m },
+ new() { ApplicationId = childA1Id, TotalPaid = 200m, TotalPending = 50m },
+ new() { ApplicationId = childA2Id, TotalPaid = 300m, TotalPending = 75m },
+ new() { ApplicationId = parentBId, TotalPaid = 500m, TotalPending = 0m },
+ new() { ApplicationId = childB1Id, TotalPaid = 400m, TotalPending = 200m }
+ });
+
+ var manager = CreateManager(repo);
+
+ // Act
+ var result = await manager.GetApplicationPaymentRollupBatchAsync(
+ [parentAId, parentBId], childMap);
+
+ // Assert
+ result.Count.ShouldBe(2);
+
+ // Parent A: 1000+200+300 paid, 100+50+75 pending
+ result[parentAId].TotalPaid.ShouldBe(1500m);
+ result[parentAId].TotalPending.ShouldBe(225m);
+ result[parentAId].ApplicationId.ShouldBe(parentAId);
+
+ // Parent B: 500+400 paid, 0+200 pending
+ result[parentBId].TotalPaid.ShouldBe(900m);
+ result[parentBId].TotalPending.ShouldBe(200m);
+ result[parentBId].ApplicationId.ShouldBe(parentBId);
+ }
+
+ [Fact]
+ public async Task Should_Handle_Application_With_No_Matching_Rollup_In_Batch()
+ {
+ // Arrange
+ var app1Id = Guid.NewGuid();
+ var app2Id = Guid.NewGuid();
+
+ var repo = Substitute.For();
+ repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>())
+ .Returns(new List
+ {
+ // Only app1 has payment data, app2 doesn't
+ new() { ApplicationId = app1Id, TotalPaid = 1000m, TotalPending = 500m }
+ });
+
+ var manager = CreateManager(repo);
+
+ // Act
+ var result = await manager.GetApplicationPaymentRollupBatchAsync(
+ [app1Id, app2Id],
+ new Dictionary>());
+
+ // Assert
+ result.Count.ShouldBe(2);
+ result[app1Id].TotalPaid.ShouldBe(1000m);
+ result[app1Id].TotalPending.ShouldBe(500m);
+
+ // app2 gets zero amounts since no data was returned
+ result[app2Id].TotalPaid.ShouldBe(0m);
+ result[app2Id].TotalPending.ShouldBe(0m);
+ result[app2Id].ApplicationId.ShouldBe(app2Id);
+ }
+
+ [Fact]
+ public async Task Should_Deduplicate_CorrelationIds_In_Batch_Repository_Call()
+ {
+ // Arrange - A child is shared between two parents (edge case)
+ var parentAId = Guid.NewGuid();
+ var parentBId = Guid.NewGuid();
+ var sharedChildId = Guid.NewGuid();
+
+ var childMap = new Dictionary>
+ {
+ { parentAId, [sharedChildId] },
+ { parentBId, [sharedChildId] }
+ };
+
+ var repo = Substitute.For();
+ repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>())
+ .Returns(new List
+ {
+ new() { ApplicationId = parentAId, TotalPaid = 100m, TotalPending = 0m },
+ new() { ApplicationId = parentBId, TotalPaid = 200m, TotalPending = 0m },
+ new() { ApplicationId = sharedChildId, TotalPaid = 50m, TotalPending = 25m }
+ });
+
+ var manager = CreateManager(repo);
+
+ // Act
+ var result = await manager.GetApplicationPaymentRollupBatchAsync(
+ [parentAId, parentBId], childMap);
+
+ // Assert
+ // Verify repository was called with deduplicated IDs (3 unique, not 4)
+ await repo.Received(1).GetBatchPaymentRollupsByCorrelationIdsAsync(
+ Arg.Is>(ids => ids.Distinct().Count() == 3));
+
+ // Both parents should include the shared child's amounts
+ result[parentAId].TotalPaid.ShouldBe(150m); // 100 + 50
+ result[parentAId].TotalPending.ShouldBe(25m); // 0 + 25
+ result[parentBId].TotalPaid.ShouldBe(250m); // 200 + 50
+ result[parentBId].TotalPending.ShouldBe(25m); // 0 + 25
+ }
+
+ [Fact]
+ public async Task Should_Make_Single_Repository_Call_For_Batch()
+ {
+ // Arrange
+ var app1Id = Guid.NewGuid();
+ var app2Id = Guid.NewGuid();
+ var child1Id = Guid.NewGuid();
+
+ var childMap = new Dictionary>
+ {
+ { app1Id, [child1Id] }
+ };
+
+ var repo = Substitute.For();
+ repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>())
+ .Returns(new List());
+
+ var manager = CreateManager(repo);
+
+ // Act
+ await manager.GetApplicationPaymentRollupBatchAsync([app1Id, app2Id], childMap);
+
+ // Assert - should only call repository once (batch optimization)
+ await repo.Received(1).GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>());
+ }
+
+ [Fact]
+ public async Task Should_Return_Empty_Dictionary_For_Empty_Application_List()
+ {
+ // Arrange
+ var repo = Substitute.For();
+ repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>())
+ .Returns(new List());
+
+ var manager = CreateManager(repo);
+
+ // Act
+ var result = await manager.GetApplicationPaymentRollupBatchAsync(
+ [], new Dictionary>());
+
+ // Assert
+ result.ShouldNotBeNull();
+ result.ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task Should_Handle_Parent_Without_Children_In_Mixed_Batch()
+ {
+ // Arrange - app1 has children, app2 does not
+ var app1Id = Guid.NewGuid();
+ var app2Id = Guid.NewGuid();
+ var childId = Guid.NewGuid();
+
+ var childMap = new Dictionary>
+ {
+ { app1Id, [childId] }
+ // app2 has no entry in childMap
+ };
+
+ var repo = Substitute.For();
+ repo.GetBatchPaymentRollupsByCorrelationIdsAsync(Arg.Any>())
+ .Returns(new List
+ {
+ new() { ApplicationId = app1Id, TotalPaid = 1000m, TotalPending = 0m },
+ new() { ApplicationId = childId, TotalPaid = 500m, TotalPending = 100m },
+ new() { ApplicationId = app2Id, TotalPaid = 300m, TotalPending = 50m }
+ });
+
+ var manager = CreateManager(repo);
+
+ // Act
+ var result = await manager.GetApplicationPaymentRollupBatchAsync(
+ [app1Id, app2Id], childMap);
+
+ // Assert
+ result[app1Id].TotalPaid.ShouldBe(1500m); // 1000 + 500
+ result[app1Id].TotalPending.ShouldBe(100m); // 0 + 100
+
+ result[app2Id].TotalPaid.ShouldBe(300m); // Only own amount
+ result[app2Id].TotalPending.ShouldBe(50m); // Only own amount
+ }
+
+ #endregion
+
+ #region Helpers
+
+ private static PaymentRequestQueryManager CreateManager(IPaymentRequestRepository repo)
+ {
+ return new PaymentRequestQueryManager(
+ repo,
+ Substitute.For(),
+ Substitute.For(),
+ null!, // CasPaymentRequestCoordinator - not used by Rollup methods
+ null! // IObjectMapper - not used by Rollup methods
+ );
+ }
+
+ #endregion
+}
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_PaymentRollup_Tests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_PaymentRollup_Tests.cs
new file mode 100644
index 000000000..17a7da11f
--- /dev/null
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_PaymentRollup_Tests.cs
@@ -0,0 +1,411 @@
+using Shouldly;
+using System;
+using System.ComponentModel;
+using System.Threading.Tasks;
+using Unity.Modules.Shared.Correlation;
+using Unity.Payments.Domain.Suppliers;
+using Unity.Payments.Enums;
+using Unity.Payments.PaymentRequests;
+using Volo.Abp.Uow;
+using Xunit;
+
+namespace Unity.Payments.Domain.PaymentRequests;
+
+[Category("Integration")]
+public class PaymentRequestRepository_PaymentRollup_Tests : PaymentsApplicationTestBase
+{
+ private readonly IPaymentRequestRepository _paymentRequestRepository;
+ private readonly ISupplierRepository _supplierRepository;
+ private readonly IUnitOfWorkManager _unitOfWorkManager;
+
+ public PaymentRequestRepository_PaymentRollup_Tests()
+ {
+ _paymentRequestRepository = GetRequiredService();
+ _supplierRepository = GetRequiredService();
+ _unitOfWorkManager = GetRequiredService();
+ }
+
+ #region PaymentStatus Case-Insensitive Matching
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task Should_Count_FullyPaid_With_Exact_Case()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId, 1000m,
+ PaymentRequestStatus.Submitted, paymentStatus: "Fully Paid");
+
+ // Act
+ var results = await _paymentRequestRepository
+ .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]);
+
+ // Assert
+ results.Count.ShouldBe(1);
+ results[0].ApplicationId.ShouldBe(correlationId);
+ results[0].TotalPaid.ShouldBe(1000m);
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task Should_Count_FullyPaid_With_UpperCase()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId, 500m,
+ PaymentRequestStatus.Submitted, paymentStatus: "FULLY PAID");
+
+ // Act
+ var results = await _paymentRequestRepository
+ .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]);
+
+ // Assert
+ results.Count.ShouldBe(1);
+ results[0].TotalPaid.ShouldBe(500m);
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task Should_Count_FullyPaid_With_LowerCase()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId, 800m,
+ PaymentRequestStatus.Submitted, paymentStatus: "fully paid");
+
+ // Act
+ var results = await _paymentRequestRepository
+ .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]);
+
+ // Assert
+ results.Count.ShouldBe(1);
+ results[0].TotalPaid.ShouldBe(800m);
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task Should_Count_FullyPaid_With_LeadingAndTrailingSpaces()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId, 300m,
+ PaymentRequestStatus.Submitted, paymentStatus: " Fully Paid ");
+
+ // Act
+ var results = await _paymentRequestRepository
+ .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]);
+
+ // Assert
+ results.Count.ShouldBe(1);
+ results[0].TotalPaid.ShouldBe(300m);
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task Should_Aggregate_FullyPaid_Across_All_Case_Variations()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId, 1000m,
+ PaymentRequestStatus.Submitted, paymentStatus: "Fully Paid");
+ await InsertPaymentRequestAsync(siteId, correlationId, 500m,
+ PaymentRequestStatus.Submitted, paymentStatus: "FULLY PAID");
+ await InsertPaymentRequestAsync(siteId, correlationId, 800m,
+ PaymentRequestStatus.Submitted, paymentStatus: "fully paid");
+ await InsertPaymentRequestAsync(siteId, correlationId, 300m,
+ PaymentRequestStatus.Submitted, paymentStatus: " Fully Paid ");
+
+ // Act
+ var results = await _paymentRequestRepository
+ .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]);
+
+ // Assert
+ results.Count.ShouldBe(1);
+ results[0].TotalPaid.ShouldBe(2600m); // 1000 + 500 + 800 + 300
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task Should_Not_Count_PartialMatch_PaymentStatus_As_Paid()
+ {
+ // Arrange - "Paid" alone should not match "Fully Paid"
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId, 200m,
+ PaymentRequestStatus.Submitted, paymentStatus: "Paid");
+
+ // Act
+ var results = await _paymentRequestRepository
+ .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]);
+
+ // Assert
+ results.Count.ShouldBe(1);
+ results[0].TotalPaid.ShouldBe(0m); // "Paid" != "Fully Paid"
+ }
+
+ #endregion
+
+ #region Pending Status Calculation
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task Should_Sum_All_Pending_Levels()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId, 1000m,
+ PaymentRequestStatus.L1Pending);
+ await InsertPaymentRequestAsync(siteId, correlationId, 2000m,
+ PaymentRequestStatus.L2Pending);
+ await InsertPaymentRequestAsync(siteId, correlationId, 3000m,
+ PaymentRequestStatus.L3Pending);
+
+ // Act
+ var results = await _paymentRequestRepository
+ .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]);
+
+ // Assert
+ results.Count.ShouldBe(1);
+ results[0].TotalPending.ShouldBe(6000m); // 1000 + 2000 + 3000
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task Should_Include_Submitted_WithNullPaymentStatus_InPending()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId, 500m,
+ PaymentRequestStatus.Submitted, paymentStatus: null, invoiceStatus: null);
+ await InsertPaymentRequestAsync(siteId, correlationId, 300m,
+ PaymentRequestStatus.Submitted, paymentStatus: null, invoiceStatus: "SentToCas");
+
+ // Act
+ var results = await _paymentRequestRepository
+ .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]);
+
+ // Assert
+ results.Count.ShouldBe(1);
+ results[0].TotalPending.ShouldBe(800m); // 500 + 300
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task Should_Exclude_Submitted_WithErrorFromCas_FromPending()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ // This one has ErrorFromCas - should NOT be pending
+ await InsertPaymentRequestAsync(siteId, correlationId, 1000m,
+ PaymentRequestStatus.Submitted, paymentStatus: null, invoiceStatus: "Error");
+ // This one has no error - SHOULD be pending
+ await InsertPaymentRequestAsync(siteId, correlationId, 200m,
+ PaymentRequestStatus.Submitted, paymentStatus: null, invoiceStatus: "SentToCas");
+
+ // Act
+ var results = await _paymentRequestRepository
+ .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]);
+
+ // Assert
+ results.Count.ShouldBe(1);
+ results[0].TotalPending.ShouldBe(200m); // Only the non-error one
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task Should_Exclude_Declined_Statuses_From_Both_Paid_And_Pending()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId, 1000m,
+ PaymentRequestStatus.L1Declined);
+ await InsertPaymentRequestAsync(siteId, correlationId, 2000m,
+ PaymentRequestStatus.L2Declined);
+ await InsertPaymentRequestAsync(siteId, correlationId, 3000m,
+ PaymentRequestStatus.L3Declined);
+
+ // Act
+ var results = await _paymentRequestRepository
+ .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]);
+
+ // Assert
+ results.Count.ShouldBe(1);
+ results[0].TotalPaid.ShouldBe(0m);
+ results[0].TotalPending.ShouldBe(0m);
+ }
+
+ #endregion
+
+ #region Mixed Paid and Pending
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task Should_Correctly_Separate_Paid_And_Pending()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ // Paid
+ await InsertPaymentRequestAsync(siteId, correlationId, 1000m,
+ PaymentRequestStatus.Submitted, paymentStatus: "Fully Paid");
+ await InsertPaymentRequestAsync(siteId, correlationId, 500m,
+ PaymentRequestStatus.Submitted, paymentStatus: "FULLY PAID");
+ // Pending
+ await InsertPaymentRequestAsync(siteId, correlationId, 2000m,
+ PaymentRequestStatus.L1Pending);
+ await InsertPaymentRequestAsync(siteId, correlationId, 800m,
+ PaymentRequestStatus.Submitted, paymentStatus: null, invoiceStatus: null);
+ // Neither paid nor pending (declined)
+ await InsertPaymentRequestAsync(siteId, correlationId, 5000m,
+ PaymentRequestStatus.L1Declined);
+
+ // Act
+ var results = await _paymentRequestRepository
+ .GetBatchPaymentRollupsByCorrelationIdsAsync([correlationId]);
+
+ // Assert
+ results.Count.ShouldBe(1);
+ results[0].TotalPaid.ShouldBe(1500m); // 1000 + 500
+ results[0].TotalPending.ShouldBe(2800m); // 2000 + 800
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task Should_Return_Rollup_For_Multiple_CorrelationIds()
+ {
+ // Arrange
+ var app1Id = Guid.NewGuid();
+ var app2Id = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ // App 1 payments
+ await InsertPaymentRequestAsync(siteId, app1Id, 1000m,
+ PaymentRequestStatus.Submitted, paymentStatus: "Fully Paid");
+ await InsertPaymentRequestAsync(siteId, app1Id, 500m,
+ PaymentRequestStatus.L1Pending);
+ // App 2 payments
+ await InsertPaymentRequestAsync(siteId, app2Id, 2000m,
+ PaymentRequestStatus.Submitted, paymentStatus: "fully paid");
+ await InsertPaymentRequestAsync(siteId, app2Id, 300m,
+ PaymentRequestStatus.L2Pending);
+
+ // Act
+ var results = await _paymentRequestRepository
+ .GetBatchPaymentRollupsByCorrelationIdsAsync([app1Id, app2Id]);
+
+ // Assert
+ results.Count.ShouldBe(2);
+
+ var app1Rollup = results.Find(r => r.ApplicationId == app1Id);
+ app1Rollup.ShouldNotBeNull();
+ app1Rollup!.TotalPaid.ShouldBe(1000m);
+ app1Rollup.TotalPending.ShouldBe(500m);
+
+ var app2Rollup = results.Find(r => r.ApplicationId == app2Id);
+ app2Rollup.ShouldNotBeNull();
+ app2Rollup!.TotalPaid.ShouldBe(2000m);
+ app2Rollup.TotalPending.ShouldBe(300m);
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task Should_Return_Empty_For_Unknown_CorrelationIds()
+ {
+ // Arrange & Act
+ using var uow = _unitOfWorkManager.Begin();
+ var results = await _paymentRequestRepository
+ .GetBatchPaymentRollupsByCorrelationIdsAsync([Guid.NewGuid()]);
+
+ // Assert
+ results.ShouldBeEmpty();
+ }
+
+ #endregion
+
+ #region Helpers
+
+ private async Task CreateSupplierAndSiteAsync()
+ {
+ using var uow = _unitOfWorkManager.Begin();
+ var siteId = Guid.NewGuid();
+ var supplier = new Supplier(Guid.NewGuid(), "TestSupplier", "SUP-001",
+ new Correlation(Guid.NewGuid(), "Test"));
+ supplier.AddSite(new Site(siteId, "001", PaymentGroup.EFT));
+ await _supplierRepository.InsertAsync(supplier, true);
+ await uow.CompleteAsync();
+ return siteId;
+ }
+
+ private async Task InsertPaymentRequestAsync(
+ Guid siteId,
+ Guid correlationId,
+ decimal amount,
+ PaymentRequestStatus status,
+ string? paymentStatus = null,
+ string? invoiceStatus = null)
+ {
+ var dto = new CreatePaymentRequestDto
+ {
+ InvoiceNumber = $"INV-{Guid.NewGuid():N}",
+ Amount = amount,
+ PayeeName = "Test Payee",
+ ContractNumber = "0000000000",
+ SupplierNumber = "SUP-001",
+ SiteId = siteId,
+ CorrelationId = correlationId,
+ CorrelationProvider = "Test",
+ ReferenceNumber = $"REF-{Guid.NewGuid():N}",
+ BatchName = "TEST_BATCH",
+ BatchNumber = 1
+ };
+
+ var paymentRequest = new PaymentRequest(Guid.NewGuid(), dto);
+ paymentRequest.SetPaymentRequestStatus(status);
+
+ if (paymentStatus != null)
+ {
+ paymentRequest.SetPaymentStatus(paymentStatus);
+ }
+
+ if (invoiceStatus != null)
+ {
+ paymentRequest.SetInvoiceStatus(invoiceStatus);
+ }
+
+ await _paymentRequestRepository.InsertAsync(paymentRequest, true);
+ }
+
+ #endregion
+}
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_Tests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_Tests.cs
new file mode 100644
index 000000000..58b82f166
--- /dev/null
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/Domain/PaymentRequests/PaymentRequestRepository_Tests.cs
@@ -0,0 +1,847 @@
+using Shouldly;
+using System;
+using System.ComponentModel;
+using System.Linq;
+using System.Threading.Tasks;
+using Unity.Modules.Shared.Correlation;
+using Unity.Payments.Codes;
+using Unity.Payments.Domain.Suppliers;
+using Unity.Payments.Enums;
+using Unity.Payments.PaymentRequests;
+using Volo.Abp.Uow;
+using Xunit;
+
+namespace Unity.Payments.Domain.PaymentRequests;
+
+[Category("Integration")]
+public class PaymentRequestRepository_Tests : PaymentsApplicationTestBase
+{
+ private readonly IPaymentRequestRepository _paymentRequestRepository;
+ private readonly ISupplierRepository _supplierRepository;
+ private readonly IUnitOfWorkManager _unitOfWorkManager;
+
+ public PaymentRequestRepository_Tests()
+ {
+ _paymentRequestRepository = GetRequiredService();
+ _supplierRepository = GetRequiredService();
+ _unitOfWorkManager = GetRequiredService();
+ }
+
+ #region GetCountByCorrelationId
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetCountByCorrelationId_Should_Return_Zero_When_No_Payments_Exist()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+
+ // Act
+ var count = await _paymentRequestRepository.GetCountByCorrelationId(correlationId);
+
+ // Assert
+ count.ShouldBe(0);
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetCountByCorrelationId_Should_Return_Correct_Count_For_Single_Payment()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.L1Pending);
+
+ // Act
+ var count = await _paymentRequestRepository.GetCountByCorrelationId(correlationId);
+
+ // Assert
+ count.ShouldBe(1);
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetCountByCorrelationId_Should_Return_Correct_Count_For_Multiple_Payments()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.L1Pending);
+ await InsertPaymentRequestAsync(siteId, correlationId, 2000m, PaymentRequestStatus.L2Pending);
+ await InsertPaymentRequestAsync(siteId, correlationId, 3000m, PaymentRequestStatus.Submitted);
+
+ // Act
+ var count = await _paymentRequestRepository.GetCountByCorrelationId(correlationId);
+
+ // Assert
+ count.ShouldBe(3);
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetCountByCorrelationId_Should_Not_Count_Other_CorrelationIds()
+ {
+ // Arrange
+ var correlationId1 = Guid.NewGuid();
+ var correlationId2 = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId1, 1000m, PaymentRequestStatus.L1Pending);
+ await InsertPaymentRequestAsync(siteId, correlationId2, 2000m, PaymentRequestStatus.L1Pending);
+
+ // Act
+ var count = await _paymentRequestRepository.GetCountByCorrelationId(correlationId1);
+
+ // Assert
+ count.ShouldBe(1);
+ }
+
+ #endregion
+
+ #region GetPaymentRequestCountBySiteId
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetPaymentRequestCountBySiteId_Should_Return_Zero_When_No_Payments_Exist()
+ {
+ // Arrange
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ // Act
+ var count = await _paymentRequestRepository.GetPaymentRequestCountBySiteId(siteId);
+
+ // Assert
+ count.ShouldBe(0);
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetPaymentRequestCountBySiteId_Should_Return_Correct_Count_For_Single_Payment()
+ {
+ // Arrange
+ var siteId = await CreateSupplierAndSiteAsync();
+ var correlationId = Guid.NewGuid();
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.L1Pending);
+
+ // Act
+ var count = await _paymentRequestRepository.GetPaymentRequestCountBySiteId(siteId);
+
+ // Assert
+ count.ShouldBe(1);
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetPaymentRequestCountBySiteId_Should_Return_Correct_Count_For_Multiple_Payments()
+ {
+ // Arrange
+ var siteId = await CreateSupplierAndSiteAsync();
+ var correlationId1 = Guid.NewGuid();
+ var correlationId2 = Guid.NewGuid();
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId1, 1000m, PaymentRequestStatus.L1Pending);
+ await InsertPaymentRequestAsync(siteId, correlationId2, 2000m, PaymentRequestStatus.L2Pending);
+
+ // Act
+ var count = await _paymentRequestRepository.GetPaymentRequestCountBySiteId(siteId);
+
+ // Assert
+ count.ShouldBe(2);
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetPaymentRequestCountBySiteId_Should_Not_Count_Other_Sites()
+ {
+ // Arrange
+ var siteId1 = await CreateSupplierAndSiteAsync();
+ var siteId2 = await CreateSupplierAndSiteAsync();
+ var correlationId = Guid.NewGuid();
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId1, correlationId, 1000m, PaymentRequestStatus.L1Pending);
+ await InsertPaymentRequestAsync(siteId2, correlationId, 2000m, PaymentRequestStatus.L1Pending);
+
+ // Act
+ var count = await _paymentRequestRepository.GetPaymentRequestCountBySiteId(siteId1);
+
+ // Assert
+ count.ShouldBe(1);
+ }
+
+ #endregion
+
+ #region GetPaymentRequestByInvoiceNumber
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetPaymentRequestByInvoiceNumber_Should_Return_Null_When_Not_Found()
+ {
+ // Arrange
+ var invoiceNumber = "NONEXISTENT-INV-001";
+
+ // Act
+ var payment = await _paymentRequestRepository.GetPaymentRequestByInvoiceNumber(invoiceNumber);
+
+ // Assert
+ payment.ShouldBeNull();
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetPaymentRequestByInvoiceNumber_Should_Return_Payment_When_Found()
+ {
+ // Arrange
+ var siteId = await CreateSupplierAndSiteAsync();
+ var correlationId = Guid.NewGuid();
+ var invoiceNumber = $"TEST-INV-{Guid.NewGuid():N}";
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.L1Pending, invoiceNumber);
+
+ // Act
+ var payment = await _paymentRequestRepository.GetPaymentRequestByInvoiceNumber(invoiceNumber);
+
+ // Assert
+ payment.ShouldNotBeNull();
+ payment.InvoiceNumber.ShouldBe(invoiceNumber);
+ payment.Amount.ShouldBe(1000m);
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetPaymentRequestByInvoiceNumber_Should_Return_Only_Matching_Invoice()
+ {
+ // Arrange
+ var siteId = await CreateSupplierAndSiteAsync();
+ var correlationId = Guid.NewGuid();
+ var invoiceNumber1 = $"TEST-INV-{Guid.NewGuid():N}";
+ var invoiceNumber2 = $"TEST-INV-{Guid.NewGuid():N}";
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.L1Pending, invoiceNumber1);
+ await InsertPaymentRequestAsync(siteId, correlationId, 2000m, PaymentRequestStatus.L2Pending, invoiceNumber2);
+
+ // Act
+ var payment = await _paymentRequestRepository.GetPaymentRequestByInvoiceNumber(invoiceNumber1);
+
+ // Assert
+ payment.ShouldNotBeNull();
+ payment.InvoiceNumber.ShouldBe(invoiceNumber1);
+ payment.Amount.ShouldBe(1000m);
+ }
+
+ #endregion
+
+ #region GetTotalPaymentRequestAmountByCorrelationIdAsync
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync_Should_Return_Zero_When_No_Payments()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+
+ // Act
+ var total = await _paymentRequestRepository.GetTotalPaymentRequestAmountByCorrelationIdAsync(correlationId);
+
+ // Assert
+ total.ShouldBe(0m);
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync_Should_Sum_Single_Payment()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted);
+
+ // Act
+ var total = await _paymentRequestRepository.GetTotalPaymentRequestAmountByCorrelationIdAsync(correlationId);
+
+ // Assert
+ total.ShouldBe(1000m);
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync_Should_Sum_Multiple_Payments()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted);
+ await InsertPaymentRequestAsync(siteId, correlationId, 2500m, PaymentRequestStatus.Submitted);
+ await InsertPaymentRequestAsync(siteId, correlationId, 3000m, PaymentRequestStatus.L1Pending);
+
+ // Act
+ var total = await _paymentRequestRepository.GetTotalPaymentRequestAmountByCorrelationIdAsync(correlationId);
+
+ // Assert
+ total.ShouldBe(6500m);
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync_Should_Exclude_L1Declined()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted);
+ await InsertPaymentRequestAsync(siteId, correlationId, 5000m, PaymentRequestStatus.L1Declined);
+
+ // Act
+ var total = await _paymentRequestRepository.GetTotalPaymentRequestAmountByCorrelationIdAsync(correlationId);
+
+ // Assert
+ total.ShouldBe(1000m); // Declined amount excluded
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync_Should_Exclude_L2Declined()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted);
+ await InsertPaymentRequestAsync(siteId, correlationId, 3000m, PaymentRequestStatus.L2Declined);
+
+ // Act
+ var total = await _paymentRequestRepository.GetTotalPaymentRequestAmountByCorrelationIdAsync(correlationId);
+
+ // Assert
+ total.ShouldBe(1000m); // Declined amount excluded
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync_Should_Exclude_L3Declined()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted);
+ await InsertPaymentRequestAsync(siteId, correlationId, 2000m, PaymentRequestStatus.L3Declined);
+
+ // Act
+ var total = await _paymentRequestRepository.GetTotalPaymentRequestAmountByCorrelationIdAsync(correlationId);
+
+ // Assert
+ total.ShouldBe(1000m); // Declined amount excluded
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync_Should_Exclude_NotFound_InvoiceStatus()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted);
+ await InsertPaymentRequestAsync(siteId, correlationId, 4000m, PaymentRequestStatus.Submitted,
+ invoiceStatus: CasPaymentRequestStatus.NotFound);
+
+ // Act
+ var total = await _paymentRequestRepository.GetTotalPaymentRequestAmountByCorrelationIdAsync(correlationId);
+
+ // Assert
+ total.ShouldBe(1000m); // NotFound invoice excluded
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetTotalPaymentRequestAmountByCorrelationIdAsync_Should_Exclude_ErrorFromCas_InvoiceStatus()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted);
+ await InsertPaymentRequestAsync(siteId, correlationId, 6000m, PaymentRequestStatus.Submitted,
+ invoiceStatus: CasPaymentRequestStatus.ErrorFromCas);
+
+ // Act
+ var total = await _paymentRequestRepository.GetTotalPaymentRequestAmountByCorrelationIdAsync(correlationId);
+
+ // Assert
+ total.ShouldBe(1000m); // ErrorFromCas invoice excluded
+ }
+
+ #endregion
+
+ #region GetPaymentRequestsBySentToCasStatusAsync
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetPaymentRequestsBySentToCasStatusAsync_Should_Return_Empty_When_No_Matching_Payments()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted,
+ invoiceStatus: CasPaymentRequestStatus.Validated);
+
+ // Act
+ var payments = await _paymentRequestRepository.GetPaymentRequestsBySentToCasStatusAsync();
+
+ // Assert
+ payments.ShouldBeEmpty();
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetPaymentRequestsBySentToCasStatusAsync_Should_Return_ServiceUnavailable_Status()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted,
+ invoiceStatus: CasPaymentRequestStatus.ServiceUnavailable);
+
+ // Act
+ var payments = await _paymentRequestRepository.GetPaymentRequestsBySentToCasStatusAsync();
+
+ // Assert
+ payments.Count.ShouldBe(1);
+ payments[0].InvoiceStatus.ShouldBe(CasPaymentRequestStatus.ServiceUnavailable);
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetPaymentRequestsBySentToCasStatusAsync_Should_Return_SentToCas_Status()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted,
+ invoiceStatus: CasPaymentRequestStatus.SentToCas);
+
+ // Act
+ var payments = await _paymentRequestRepository.GetPaymentRequestsBySentToCasStatusAsync();
+
+ // Assert
+ payments.Count.ShouldBe(1);
+ payments[0].InvoiceStatus.ShouldBe(CasPaymentRequestStatus.SentToCas);
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetPaymentRequestsBySentToCasStatusAsync_Should_Return_NeverValidated_Status()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted,
+ invoiceStatus: CasPaymentRequestStatus.NeverValidated);
+
+ // Act
+ var payments = await _paymentRequestRepository.GetPaymentRequestsBySentToCasStatusAsync();
+
+ // Assert
+ payments.Count.ShouldBe(1);
+ payments[0].InvoiceStatus.ShouldBe(CasPaymentRequestStatus.NeverValidated);
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetPaymentRequestsBySentToCasStatusAsync_Should_Return_All_ReCheck_Statuses()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted,
+ invoiceStatus: CasPaymentRequestStatus.ServiceUnavailable);
+ await InsertPaymentRequestAsync(siteId, correlationId, 2000m, PaymentRequestStatus.Submitted,
+ invoiceStatus: CasPaymentRequestStatus.SentToCas);
+ await InsertPaymentRequestAsync(siteId, correlationId, 3000m, PaymentRequestStatus.Submitted,
+ invoiceStatus: CasPaymentRequestStatus.NeverValidated);
+
+ // Act
+ var payments = await _paymentRequestRepository.GetPaymentRequestsBySentToCasStatusAsync();
+
+ // Assert
+ payments.Count.ShouldBe(3);
+ payments.Any(p => p.InvoiceStatus == CasPaymentRequestStatus.ServiceUnavailable).ShouldBeTrue();
+ payments.Any(p => p.InvoiceStatus == CasPaymentRequestStatus.SentToCas).ShouldBeTrue();
+ payments.Any(p => p.InvoiceStatus == CasPaymentRequestStatus.NeverValidated).ShouldBeTrue();
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetPaymentRequestsBySentToCasStatusAsync_Should_Not_Return_Null_InvoiceStatus()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted,
+ invoiceStatus: null);
+ await InsertPaymentRequestAsync(siteId, correlationId, 2000m, PaymentRequestStatus.Submitted,
+ invoiceStatus: CasPaymentRequestStatus.SentToCas);
+
+ // Act
+ var payments = await _paymentRequestRepository.GetPaymentRequestsBySentToCasStatusAsync();
+
+ // Assert
+ payments.Count.ShouldBe(1);
+ payments[0].InvoiceStatus.ShouldBe(CasPaymentRequestStatus.SentToCas);
+ }
+
+ #endregion
+
+ #region GetPaymentRequestsByFailedsStatusAsync
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetPaymentRequestsByFailedsStatusAsync_Should_Return_Empty_When_No_Matching_Payments()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted,
+ invoiceStatus: CasPaymentRequestStatus.Validated);
+
+ // Act
+ var payments = await _paymentRequestRepository.GetPaymentRequestsByFailedsStatusAsync();
+
+ // Assert
+ payments.ShouldBeEmpty();
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetPaymentRequestsByFailedsStatusAsync_Should_Return_ServiceUnavailable_Status()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ var paymentRequest = await InsertAndGetPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted,
+ invoiceStatus: CasPaymentRequestStatus.ServiceUnavailable);
+
+ // Update to trigger LastModificationTime
+ paymentRequest.SetCasResponse("Test response");
+ await _paymentRequestRepository.UpdateAsync(paymentRequest, true);
+
+ // Act
+ var payments = await _paymentRequestRepository.GetPaymentRequestsByFailedsStatusAsync();
+
+ // Assert
+ payments.Count.ShouldBe(1);
+ payments[0].InvoiceStatus.ShouldBe(CasPaymentRequestStatus.ServiceUnavailable);
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetPaymentRequestsByFailedsStatusAsync_Should_Return_ErrorFromCas_Status()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ var paymentRequest = await InsertAndGetPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted,
+ invoiceStatus: CasPaymentRequestStatus.ErrorFromCas);
+
+ // Update to trigger LastModificationTime
+ paymentRequest.SetCasResponse("Test response");
+ await _paymentRequestRepository.UpdateAsync(paymentRequest, true);
+
+ // Act
+ var payments = await _paymentRequestRepository.GetPaymentRequestsByFailedsStatusAsync();
+
+ // Assert
+ payments.Count.ShouldBe(1);
+ payments[0].InvoiceStatus.ShouldBe(CasPaymentRequestStatus.ErrorFromCas);
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetPaymentRequestsByFailedsStatusAsync_Should_Return_Both_Failed_Statuses()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ var paymentRequest1 = await InsertAndGetPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted,
+ invoiceStatus: CasPaymentRequestStatus.ServiceUnavailable);
+ var paymentRequest2 = await InsertAndGetPaymentRequestAsync(siteId, correlationId, 2000m, PaymentRequestStatus.Submitted,
+ invoiceStatus: CasPaymentRequestStatus.ErrorFromCas);
+
+ // Update to trigger LastModificationTime
+ paymentRequest1.SetCasResponse("Test response");
+ await _paymentRequestRepository.UpdateAsync(paymentRequest1, true);
+ paymentRequest2.SetCasResponse("Test response");
+ await _paymentRequestRepository.UpdateAsync(paymentRequest2, true);
+
+ // Act
+ var payments = await _paymentRequestRepository.GetPaymentRequestsByFailedsStatusAsync();
+
+ // Assert
+ payments.Count.ShouldBe(2);
+ payments.Any(p => p.InvoiceStatus == CasPaymentRequestStatus.ServiceUnavailable).ShouldBeTrue();
+ payments.Any(p => p.InvoiceStatus == CasPaymentRequestStatus.ErrorFromCas).ShouldBeTrue();
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetPaymentRequestsByFailedsStatusAsync_Should_Not_Return_Null_InvoiceStatus()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ var paymentRequest1 = await InsertAndGetPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted,
+ invoiceStatus: null);
+ var paymentRequest2 = await InsertAndGetPaymentRequestAsync(siteId, correlationId, 2000m, PaymentRequestStatus.Submitted,
+ invoiceStatus: CasPaymentRequestStatus.ErrorFromCas);
+
+ // Update to trigger LastModificationTime (only update the one with ErrorFromCas)
+ paymentRequest2.SetCasResponse("Test response");
+ await _paymentRequestRepository.UpdateAsync(paymentRequest2, true);
+
+ // Act
+ var payments = await _paymentRequestRepository.GetPaymentRequestsByFailedsStatusAsync();
+
+ // Assert
+ payments.Count.ShouldBe(1);
+ payments[0].InvoiceStatus.ShouldBe(CasPaymentRequestStatus.ErrorFromCas);
+ }
+
+ #endregion
+
+ #region GetPaymentPendingListByCorrelationIdAsync
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetPaymentPendingListByCorrelationIdAsync_Should_Return_Empty_When_No_Pending()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.Submitted);
+
+ // Act
+ var payments = await _paymentRequestRepository.GetPaymentPendingListByCorrelationIdAsync(correlationId);
+
+ // Assert
+ payments.ShouldBeEmpty();
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetPaymentPendingListByCorrelationIdAsync_Should_Return_L1Pending()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.L1Pending);
+
+ // Act
+ var payments = await _paymentRequestRepository.GetPaymentPendingListByCorrelationIdAsync(correlationId);
+
+ // Assert
+ payments.Count.ShouldBe(1);
+ payments[0].Status.ShouldBe(PaymentRequestStatus.L1Pending);
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetPaymentPendingListByCorrelationIdAsync_Should_Return_L2Pending()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.L2Pending);
+
+ // Act
+ var payments = await _paymentRequestRepository.GetPaymentPendingListByCorrelationIdAsync(correlationId);
+
+ // Assert
+ payments.Count.ShouldBe(1);
+ payments[0].Status.ShouldBe(PaymentRequestStatus.L2Pending);
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetPaymentPendingListByCorrelationIdAsync_Should_Return_All_Pending_Statuses()
+ {
+ // Arrange
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.L1Pending);
+ await InsertPaymentRequestAsync(siteId, correlationId, 2000m, PaymentRequestStatus.L2Pending);
+ await InsertPaymentRequestAsync(siteId, correlationId, 3000m, PaymentRequestStatus.Submitted);
+
+ // Act
+ var payments = await _paymentRequestRepository.GetPaymentPendingListByCorrelationIdAsync(correlationId);
+
+ // Assert
+ payments.Count.ShouldBe(2);
+ payments.Any(p => p.Status == PaymentRequestStatus.L1Pending).ShouldBeTrue();
+ payments.Any(p => p.Status == PaymentRequestStatus.L2Pending).ShouldBeTrue();
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetPaymentPendingListByCorrelationIdAsync_Should_Not_Return_L3Pending()
+ {
+ // Arrange - The method only returns L1 and L2 pending
+ var correlationId = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId, 1000m, PaymentRequestStatus.L3Pending);
+ await InsertPaymentRequestAsync(siteId, correlationId, 2000m, PaymentRequestStatus.L1Pending);
+
+ // Act
+ var payments = await _paymentRequestRepository.GetPaymentPendingListByCorrelationIdAsync(correlationId);
+
+ // Assert
+ payments.Count.ShouldBe(1);
+ payments[0].Status.ShouldBe(PaymentRequestStatus.L1Pending);
+ }
+
+ [Fact]
+ [Trait("Category", "Integration")]
+ public async Task GetPaymentPendingListByCorrelationIdAsync_Should_Only_Return_Matching_CorrelationId()
+ {
+ // Arrange
+ var correlationId1 = Guid.NewGuid();
+ var correlationId2 = Guid.NewGuid();
+ var siteId = await CreateSupplierAndSiteAsync();
+
+ using var uow = _unitOfWorkManager.Begin();
+ await InsertPaymentRequestAsync(siteId, correlationId1, 1000m, PaymentRequestStatus.L1Pending);
+ await InsertPaymentRequestAsync(siteId, correlationId2, 2000m, PaymentRequestStatus.L1Pending);
+
+ // Act
+ var payments = await _paymentRequestRepository.GetPaymentPendingListByCorrelationIdAsync(correlationId1);
+
+ // Assert
+ payments.Count.ShouldBe(1);
+ payments[0].CorrelationId.ShouldBe(correlationId1);
+ }
+
+ #endregion
+
+ #region Helpers
+
+ private async Task CreateSupplierAndSiteAsync()
+ {
+ using var uow = _unitOfWorkManager.Begin();
+ var siteId = Guid.NewGuid();
+ var supplier = new Supplier(Guid.NewGuid(), "TestSupplier", "SUP-001",
+ new Correlation(Guid.NewGuid(), "Test"));
+ supplier.AddSite(new Site(siteId, "001", PaymentGroup.EFT));
+ await _supplierRepository.InsertAsync(supplier, true);
+ await uow.CompleteAsync();
+ return siteId;
+ }
+
+ private async Task InsertPaymentRequestAsync(
+ Guid siteId,
+ Guid correlationId,
+ decimal amount,
+ PaymentRequestStatus status,
+ string? customInvoiceNumber = null,
+ string? paymentStatus = null,
+ string? invoiceStatus = null)
+ {
+ await InsertAndGetPaymentRequestAsync(siteId, correlationId, amount, status,
+ customInvoiceNumber, paymentStatus, invoiceStatus);
+ }
+
+ private async Task InsertAndGetPaymentRequestAsync(
+ Guid siteId,
+ Guid correlationId,
+ decimal amount,
+ PaymentRequestStatus status,
+ string? customInvoiceNumber = null,
+ string? paymentStatus = null,
+ string? invoiceStatus = null)
+ {
+ var invoiceNumber = customInvoiceNumber ?? $"INV-{Guid.NewGuid():N}";
+ var dto = new CreatePaymentRequestDto
+ {
+ InvoiceNumber = invoiceNumber,
+ Amount = amount,
+ PayeeName = "Test Payee",
+ ContractNumber = "0000000000",
+ SupplierNumber = "SUP-001",
+ SiteId = siteId,
+ CorrelationId = correlationId,
+ CorrelationProvider = "Test",
+ ReferenceNumber = $"REF-{Guid.NewGuid():N}",
+ BatchName = "TEST_BATCH",
+ BatchNumber = 1
+ };
+
+ var paymentRequest = new PaymentRequest(Guid.NewGuid(), dto);
+ paymentRequest.SetPaymentRequestStatus(status);
+
+ if (paymentStatus != null)
+ {
+ paymentRequest.SetPaymentStatus(paymentStatus);
+ }
+
+ if (invoiceStatus != null)
+ {
+ paymentRequest.SetInvoiceStatus(invoiceStatus);
+ }
+
+ await _paymentRequestRepository.InsertAsync(paymentRequest, true);
+ return paymentRequest;
+ }
+
+ #endregion
+}
diff --git a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/ViewComponents/PaymentInfoViewComponentTests.cs b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/ViewComponents/PaymentInfoViewComponentTests.cs
index 0cd3660d8..58d70e1c0 100644
--- a/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/ViewComponents/PaymentInfoViewComponentTests.cs
+++ b/applications/Unity.GrantManager/modules/Unity.Payments/test/Unity.Payments.Application.Tests/ViewComponents/PaymentInfoViewComponentTests.cs
@@ -4,12 +4,8 @@
using NSubstitute;
using Shouldly;
using System;
-using System.Collections.Generic;
-using System.Linq;
using System.Threading.Tasks;
-using Unity.GrantManager.Applications;
using Unity.GrantManager.GrantApplications;
-using Unity.Payments.Enums;
using Unity.Payments.PaymentRequests;
using Unity.Payments.Web.Views.Shared.Components.PaymentInfo;
using Volo.Abp.DependencyInjection;
@@ -28,7 +24,7 @@ public PaymentInfoViewComponentTests()
}
[Fact]
- public async Task PaymentInfo_Should_Calculate_TotalPaid_And_TotalPending_For_Current_Application()
+ public async Task PaymentInfo_Should_Display_TotalPaid_And_TotalPending_From_Rollup()
{
// Arrange
var applicationId = Guid.NewGuid();
@@ -41,29 +37,25 @@ public async Task PaymentInfo_Should_Calculate_TotalPaid_And_TotalPending_For_Cu
RequestedAmount = 10000,
RecommendedAmount = 8000,
ApprovedAmount = 7000,
- Applicant = new GrantManager.GrantApplications.GrantApplicationApplicantDto { Id = applicantId }
+ Applicant = new GrantApplicationApplicantDto { Id = applicantId }
};
- var paymentRequests = new List
+ var rollup = new ApplicationPaymentRollupDto
{
- new() { Amount = 1000, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" }, // Paid
- new() { Amount = 500, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" }, // Paid
- new() { Amount = 2000, Status = PaymentRequestStatus.L1Pending }, // Pending
- new() { Amount = 1500, Status = PaymentRequestStatus.L1Declined }, // Not counted
- new() { Amount = 800, Status = PaymentRequestStatus.Paid }, // Not counted (no PaymentStatus = "Fully Paid")
+ ApplicationId = applicationId,
+ TotalPaid = 1500m,
+ TotalPending = 2000m
};
var appService = Substitute.For();
var paymentRequestService = Substitute.For();
var featureChecker = Substitute.For();
- var applicationLinksService = Substitute.For();
appService.GetAsync(applicationId).Returns(applicationDto);
- paymentRequestService.GetListByApplicationIdAsync(applicationId).Returns(paymentRequests);
+ paymentRequestService.GetApplicationPaymentRollupAsync(applicationId).Returns(rollup);
featureChecker.IsEnabledAsync("Unity.Payments").Returns(true);
- applicationLinksService.GetListByApplicationAsync(applicationId).Returns([]);
- var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker, applicationLinksService);
+ var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker);
// Act
var result = await viewComponent.InvokeAsync(applicationId, applicationFormVersionId) as ViewViewComponentResult;
@@ -71,338 +63,86 @@ public async Task PaymentInfo_Should_Calculate_TotalPaid_And_TotalPending_For_Cu
// Assert
model.ShouldNotBeNull();
- model.TotalPaid.ShouldBe(1500m); // 1000 + 500 (PaymentStatus = "Fully Paid")
- model.TotalPendingAmounts.ShouldBe(2000m); // 2000 (L1Pending only)
+ model.TotalPaid.ShouldBe(1500m);
+ model.TotalPendingAmounts.ShouldBe(2000m);
model.RemainingAmount.ShouldBe(5500m); // 7000 - 1500
}
[Fact]
- public async Task PaymentInfo_Should_Aggregate_TotalPaid_From_Child_Applications()
+ public async Task PaymentInfo_Should_Calculate_RemainingAmount_From_ApprovedAmount_Minus_TotalPaid()
{
// Arrange
- var parentAppId = Guid.NewGuid();
- var childApp1Id = Guid.NewGuid();
- var childApp2Id = Guid.NewGuid();
- var applicationFormVersionId = Guid.NewGuid();
- var applicantId = Guid.NewGuid();
-
- var parentApplicationDto = new GrantApplicationDto
- {
- Id = parentAppId,
- ApprovedAmount = 10000,
- Applicant = new GrantManager.GrantApplications.GrantApplicationApplicantDto { Id = applicantId }
- };
-
- var parentPayments = new List
- {
- new() { Amount = 1000, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" }
- };
-
- var childApp1Payments = new List
- {
- new() { CorrelationId = childApp1Id, Amount = 500, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" }
- };
-
- var childApp2Payments = new List
- {
- new() { CorrelationId = childApp2Id, Amount = 800, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" }
- };
-
- var childLinks = new List
- {
- new() { ApplicationId = childApp1Id, LinkType = ApplicationLinkType.Child },
- new() { ApplicationId = childApp2Id, LinkType = ApplicationLinkType.Child }
- };
-
- var allChildPayments = childApp1Payments.Concat(childApp2Payments).ToList();
-
- var appService = Substitute.For();
- var paymentRequestService = Substitute.For();
- var featureChecker = Substitute.For();
- var applicationLinksService = Substitute.For();
-
- appService.GetAsync(parentAppId).Returns(parentApplicationDto);
- paymentRequestService.GetListByApplicationIdAsync(parentAppId).Returns(parentPayments);
- applicationLinksService.GetListByApplicationAsync(parentAppId).Returns(childLinks);
- paymentRequestService.GetListByApplicationIdsAsync(Arg.Any>()).Returns(allChildPayments);
- featureChecker.IsEnabledAsync("Unity.Payments").Returns(true);
-
- var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker, applicationLinksService);
-
- // Act
- var result = await viewComponent.InvokeAsync(parentAppId, applicationFormVersionId) as ViewViewComponentResult;
- var model = result!.ViewData!.Model as PaymentInfoViewModel;
-
- // Assert
- model.ShouldNotBeNull();
- model.TotalPaid.ShouldBe(2300m); // 1000 (parent) + 500 (child1) + 800 (child2)
- }
-
- [Fact]
- public async Task PaymentInfo_Should_Aggregate_TotalPendingAmounts_From_Child_Applications()
- {
- // Arrange
- var parentAppId = Guid.NewGuid();
- var childApp1Id = Guid.NewGuid();
- var childApp2Id = Guid.NewGuid();
- var applicationFormVersionId = Guid.NewGuid();
- var applicantId = Guid.NewGuid();
-
- var parentApplicationDto = new GrantApplicationDto
- {
- Id = parentAppId,
- ApprovedAmount = 10000,
- Applicant = new GrantManager.GrantApplications.GrantApplicationApplicantDto { Id = applicantId }
- };
-
- var parentPayments = new List
- {
- new() { Amount = 2000, Status = PaymentRequestStatus.L1Pending }
- };
-
- var childApp1Payments = new List
- {
- new() { CorrelationId = childApp1Id, Amount = 1000, Status = PaymentRequestStatus.L1Pending }
- };
-
- var childApp2Payments = new List
- {
- new() { CorrelationId = childApp2Id, Amount = 500, Status = PaymentRequestStatus.L1Pending }
- };
-
- var childLinks = new List
- {
- new() { ApplicationId = childApp1Id, LinkType = ApplicationLinkType.Child },
- new() { ApplicationId = childApp2Id, LinkType = ApplicationLinkType.Child }
- };
-
- var allChildPayments = childApp1Payments.Concat(childApp2Payments).ToList();
-
- var appService = Substitute.For();
- var paymentRequestService = Substitute.For();
- var featureChecker = Substitute.For();
- var applicationLinksService = Substitute.For();
-
- appService.GetAsync(parentAppId).Returns(parentApplicationDto);
- paymentRequestService.GetListByApplicationIdAsync(parentAppId).Returns(parentPayments);
- applicationLinksService.GetListByApplicationAsync(parentAppId).Returns(childLinks);
- paymentRequestService.GetListByApplicationIdsAsync(Arg.Any>()).Returns(allChildPayments);
- featureChecker.IsEnabledAsync("Unity.Payments").Returns(true);
-
- var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker, applicationLinksService);
-
- // Act
- var result = await viewComponent.InvokeAsync(parentAppId, applicationFormVersionId) as ViewViewComponentResult;
- var model = result!.ViewData!.Model as PaymentInfoViewModel;
-
- // Assert
- model.ShouldNotBeNull();
- model.TotalPendingAmounts.ShouldBe(3500m); // 2000 (parent) + 1000 (child1) + 500 (child2)
- }
-
- [Fact]
- public async Task PaymentInfo_Should_Filter_Only_Child_LinkType()
- {
- // Arrange
- var parentAppId = Guid.NewGuid();
- var childAppId = Guid.NewGuid();
- var relatedAppId = Guid.NewGuid();
- var parentLinkAppId = Guid.NewGuid();
- var applicationFormVersionId = Guid.NewGuid();
- var applicantId = Guid.NewGuid();
-
- var parentApplicationDto = new GrantApplicationDto
- {
- Id = parentAppId,
- ApprovedAmount = 10000,
- Applicant = new GrantManager.GrantApplications.GrantApplicationApplicantDto { Id = applicantId }
- };
-
- var parentPayments = new List
- {
- new() { Amount = 1000, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" }
- };
-
- var childPayments = new List
- {
- new() { CorrelationId = childAppId, Amount = 500, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" }
- };
-
- var links = new List
- {
- new() { ApplicationId = childAppId, LinkType = ApplicationLinkType.Child }, // Should be included
- new() { ApplicationId = relatedAppId, LinkType = ApplicationLinkType.Related }, // Should be excluded
- new() { ApplicationId = parentLinkAppId, LinkType = ApplicationLinkType.Parent } // Should be excluded
- };
-
- var appService = Substitute.For();
- var paymentRequestService = Substitute.For();
- var featureChecker = Substitute.For();
- var applicationLinksService = Substitute.For();
-
- appService.GetAsync(parentAppId).Returns(parentApplicationDto);
- paymentRequestService.GetListByApplicationIdAsync(parentAppId).Returns(parentPayments);
- applicationLinksService.GetListByApplicationAsync(parentAppId).Returns(links);
- paymentRequestService.GetListByApplicationIdsAsync(Arg.Any>()).Returns(childPayments);
- featureChecker.IsEnabledAsync("Unity.Payments").Returns(true);
-
- var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker, applicationLinksService);
-
- // Act
- var result = await viewComponent.InvokeAsync(parentAppId, applicationFormVersionId) as ViewViewComponentResult;
- var model = result!.ViewData!.Model as PaymentInfoViewModel;
-
- // Assert
- model.ShouldNotBeNull();
- model.TotalPaid.ShouldBe(1500m); // 1000 (parent) + 500 (only child, not related or parent links)
- }
-
- [Fact]
- public async Task PaymentInfo_Should_Exclude_SelfReferences()
- {
- // Arrange
- var appId = Guid.NewGuid();
- var childAppId = Guid.NewGuid();
+ var applicationId = Guid.NewGuid();
var applicationFormVersionId = Guid.NewGuid();
var applicantId = Guid.NewGuid();
var applicationDto = new GrantApplicationDto
{
- Id = appId,
+ Id = applicationId,
ApprovedAmount = 10000,
- Applicant = new GrantManager.GrantApplications.GrantApplicationApplicantDto { Id = applicantId }
- };
-
- var appPayments = new List
- {
- new() { Amount = 1000, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" }
+ Applicant = new GrantApplicationApplicantDto { Id = applicantId }
};
- var childPayments = new List
+ var rollup = new ApplicationPaymentRollupDto
{
- new() { CorrelationId = childAppId, Amount = 500, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" }
- };
-
- var links = new List
- {
- new() { ApplicationId = appId, LinkType = ApplicationLinkType.Child }, // Self-reference - should be excluded
- new() { ApplicationId = childAppId, LinkType = ApplicationLinkType.Child } // Real child - should be included
+ ApplicationId = applicationId,
+ TotalPaid = 3500m,
+ TotalPending = 1000m
};
var appService = Substitute.For();
var paymentRequestService = Substitute.For();
var featureChecker = Substitute.For();
- var applicationLinksService = Substitute.For();
-
- appService.GetAsync(appId).Returns(applicationDto);
- paymentRequestService.GetListByApplicationIdAsync(appId).Returns(appPayments);
- applicationLinksService.GetListByApplicationAsync(appId).Returns(links);
- paymentRequestService.GetListByApplicationIdsAsync(Arg.Any>()).Returns(childPayments);
- featureChecker.IsEnabledAsync("Unity.Payments").Returns(true);
-
- var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker, applicationLinksService);
-
- // Act
- var result = await viewComponent.InvokeAsync(appId, applicationFormVersionId) as ViewViewComponentResult;
- var model = result!.ViewData!.Model as PaymentInfoViewModel;
-
- // Assert
- model.ShouldNotBeNull();
- model.TotalPaid.ShouldBe(1500m); // 1000 (parent) + 500 (child only, self-reference excluded)
- // Verify that GetListByApplicationIdsAsync was called with only the child app, not the self-reference
- await paymentRequestService.Received(1).GetListByApplicationIdsAsync(
- Arg.Is>(list => list.Count == 1 && list.Contains(childAppId) && !list.Contains(appId))
- );
- }
-
- [Fact]
- public async Task PaymentInfo_Should_Handle_NoChildApplications()
- {
- // Arrange
- var appId = Guid.NewGuid();
- var applicationFormVersionId = Guid.NewGuid();
- var applicantId = Guid.NewGuid();
-
- var applicationDto = new GrantApplicationDto
- {
- Id = appId,
- RequestedAmount = 10000,
- RecommendedAmount = 8000,
- ApprovedAmount = 7000,
- Applicant = new GrantManager.GrantApplications.GrantApplicationApplicantDto { Id = applicantId }
- };
-
- var parentPayments = new List
- {
- new() { Amount = 1000, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" },
- new() { Amount = 2000, Status = PaymentRequestStatus.L1Pending }
- };
-
- var appService = Substitute.For();
- var paymentRequestService = Substitute.For();
- var featureChecker = Substitute.For();
- var applicationLinksService = Substitute.For();
-
- appService.GetAsync(appId).Returns(applicationDto);
- paymentRequestService.GetListByApplicationIdAsync(appId).Returns(parentPayments);
- applicationLinksService.GetListByApplicationAsync(appId).Returns(new List());
+ appService.GetAsync(applicationId).Returns(applicationDto);
+ paymentRequestService.GetApplicationPaymentRollupAsync(applicationId).Returns(rollup);
featureChecker.IsEnabledAsync("Unity.Payments").Returns(true);
- var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker, applicationLinksService);
+ var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker);
// Act
- var result = await viewComponent.InvokeAsync(appId, applicationFormVersionId) as ViewViewComponentResult;
+ var result = await viewComponent.InvokeAsync(applicationId, applicationFormVersionId) as ViewViewComponentResult;
var model = result!.ViewData!.Model as PaymentInfoViewModel;
// Assert
model.ShouldNotBeNull();
- model.TotalPaid.ShouldBe(1000m); // Only parent payments (PaymentStatus = "Fully Paid")
- model.TotalPendingAmounts.ShouldBe(2000m); // 2000 (L1Pending only)
- model.RemainingAmount.ShouldBe(6000m); // 7000 - 1000
-
- // Verify that GetListByApplicationIdsAsync was NOT called since there are no children
- await paymentRequestService.DidNotReceive().GetListByApplicationIdsAsync(Arg.Any>());
+ model.RemainingAmount.ShouldBe(6500m); // 10000 - 3500
}
[Fact]
- public async Task PaymentInfo_Should_Handle_ChildApplications_WithNoPaymentRequests()
+ public async Task PaymentInfo_Should_Include_Child_Application_Amounts_By_Rollup()
{
+ // The ViewComponent now delegates child aggregation to the service layer.
+ // This test verifies it correctly uses the pre-aggregated rollup.
// Arrange
var parentAppId = Guid.NewGuid();
- var childAppId = Guid.NewGuid();
var applicationFormVersionId = Guid.NewGuid();
var applicantId = Guid.NewGuid();
- var parentApplicationDto = new GrantApplicationDto
+ var applicationDto = new GrantApplicationDto
{
Id = parentAppId,
ApprovedAmount = 10000,
- Applicant = new GrantManager.GrantApplications.GrantApplicationApplicantDto { Id = applicantId }
+ Applicant = new GrantApplicationApplicantDto { Id = applicantId }
};
- var parentPayments = new List
+ // Rollup includes parent + child amounts (pre-aggregated by service)
+ var rollup = new ApplicationPaymentRollupDto
{
- new() { Amount = 1000, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" }
- };
-
- var childLinks = new List
- {
- new() { ApplicationId = childAppId, LinkType = ApplicationLinkType.Child }
+ ApplicationId = parentAppId,
+ TotalPaid = 2300m, // e.g., 1000 (parent) + 500 (child1) + 800 (child2)
+ TotalPending = 3500m // e.g., 2000 (parent) + 1000 (child1) + 500 (child2)
};
var appService = Substitute.For();
var paymentRequestService = Substitute.For();
var featureChecker = Substitute.For();
- var applicationLinksService = Substitute.For();
- appService.GetAsync(parentAppId).Returns(parentApplicationDto);
- paymentRequestService.GetListByApplicationIdAsync(parentAppId).Returns(parentPayments);
- applicationLinksService.GetListByApplicationAsync(parentAppId).Returns(childLinks);
- paymentRequestService.GetListByApplicationIdsAsync(Arg.Any>()).Returns([]); // Empty list
+ appService.GetAsync(parentAppId).Returns(applicationDto);
+ paymentRequestService.GetApplicationPaymentRollupAsync(parentAppId).Returns(rollup);
featureChecker.IsEnabledAsync("Unity.Payments").Returns(true);
- var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker, applicationLinksService);
+ var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker);
// Act
var result = await viewComponent.InvokeAsync(parentAppId, applicationFormVersionId) as ViewViewComponentResult;
@@ -410,12 +150,13 @@ public async Task PaymentInfo_Should_Handle_ChildApplications_WithNoPaymentReque
// Assert
model.ShouldNotBeNull();
- model.TotalPaid.ShouldBe(1000m); // Only parent payments (child has none)
- model.TotalPendingAmounts.ShouldBe(0m); // No pending payments
+ model.TotalPaid.ShouldBe(2300m);
+ model.TotalPendingAmounts.ShouldBe(3500m);
+ model.RemainingAmount.ShouldBe(7700m); // 10000 - 2300
}
[Fact]
- public async Task PaymentInfo_Should_Exclude_Declined_Statuses_From_Pending()
+ public async Task PaymentInfo_Should_Handle_Zero_Payments()
{
// Arrange
var appId = Guid.NewGuid();
@@ -425,30 +166,26 @@ public async Task PaymentInfo_Should_Exclude_Declined_Statuses_From_Pending()
var applicationDto = new GrantApplicationDto
{
Id = appId,
- ApprovedAmount = 10000,
- Applicant = new GrantManager.GrantApplications.GrantApplicationApplicantDto { Id = applicantId }
+ ApprovedAmount = 5000,
+ Applicant = new GrantApplicationApplicantDto { Id = applicantId }
};
- var paymentRequests = new List
+ var rollup = new ApplicationPaymentRollupDto
{
- new() { Amount = 1000, Status = PaymentRequestStatus.L1Pending }, // Pending
- new() { Amount = 500, Status = PaymentRequestStatus.Paid, PaymentStatus = "Fully Paid" }, // Paid
- new() { Amount = 2000, Status = PaymentRequestStatus.L1Declined }, // Not pending
- new() { Amount = 1500, Status = PaymentRequestStatus.L2Declined }, // Not pending
- new() { Amount = 1200, Status = PaymentRequestStatus.L3Declined } // Not pending
+ ApplicationId = appId,
+ TotalPaid = 0m,
+ TotalPending = 0m
};
var appService = Substitute.For();
var paymentRequestService = Substitute.For();
var featureChecker = Substitute.For();
- var applicationLinksService = Substitute.For();
appService.GetAsync(appId).Returns(applicationDto);
- paymentRequestService.GetListByApplicationIdAsync(appId).Returns(paymentRequests);
- applicationLinksService.GetListByApplicationAsync(appId).Returns([]);
+ paymentRequestService.GetApplicationPaymentRollupAsync(appId).Returns(rollup);
featureChecker.IsEnabledAsync("Unity.Payments").Returns(true);
- var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker, applicationLinksService);
+ var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker);
// Act
var result = await viewComponent.InvokeAsync(appId, applicationFormVersionId) as ViewViewComponentResult;
@@ -456,12 +193,13 @@ public async Task PaymentInfo_Should_Exclude_Declined_Statuses_From_Pending()
// Assert
model.ShouldNotBeNull();
- model.TotalPendingAmounts.ShouldBe(1000m); // Only L1Pending status
- model.TotalPaid.ShouldBe(500m); // PaymentStatus = "Fully Paid"
+ model.TotalPaid.ShouldBe(0m);
+ model.TotalPendingAmounts.ShouldBe(0m);
+ model.RemainingAmount.ShouldBe(5000m); // 5000 - 0
}
[Fact]
- public async Task PaymentInfo_Should_Handle_PaymentStatus_FullyPaid_CaseInsensitive_WithSpaces()
+ public async Task PaymentInfo_Should_Map_RequestedAmount_And_RecommendedAmount()
{
// Arrange
var appId = Guid.NewGuid();
@@ -471,30 +209,28 @@ public async Task PaymentInfo_Should_Handle_PaymentStatus_FullyPaid_CaseInsensit
var applicationDto = new GrantApplicationDto
{
Id = appId,
+ RequestedAmount = 15000,
+ RecommendedAmount = 12000,
ApprovedAmount = 10000,
- Applicant = new GrantManager.GrantApplications.GrantApplicationApplicantDto { Id = applicantId }
+ Applicant = new GrantApplicationApplicantDto { Id = applicantId }
};
- var paymentRequests = new List
+ var rollup = new ApplicationPaymentRollupDto
{
- new() { Amount = 1000, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid" }, // Exact match
- new() { Amount = 500, Status = PaymentRequestStatus.Submitted, PaymentStatus = "FULLY PAID" }, // Upper case
- new() { Amount = 800, Status = PaymentRequestStatus.Submitted, PaymentStatus = "fully paid" }, // Lower case
- new() { Amount = 300, Status = PaymentRequestStatus.Submitted, PaymentStatus = " Fully Paid " }, // With spaces
- new() { Amount = 200, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Paid" }, // Not fully paid
+ ApplicationId = appId,
+ TotalPaid = 0m,
+ TotalPending = 0m
};
var appService = Substitute.For();
var paymentRequestService = Substitute.For();
var featureChecker = Substitute.For();
- var applicationLinksService = Substitute.For();
appService.GetAsync(appId).Returns(applicationDto);
- paymentRequestService.GetListByApplicationIdAsync(appId).Returns(paymentRequests);
- applicationLinksService.GetListByApplicationAsync(appId).Returns([]);
+ paymentRequestService.GetApplicationPaymentRollupAsync(appId).Returns(rollup);
featureChecker.IsEnabledAsync("Unity.Payments").Returns(true);
- var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker, applicationLinksService);
+ var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker);
// Act
var result = await viewComponent.InvokeAsync(appId, applicationFormVersionId) as ViewViewComponentResult;
@@ -502,44 +238,28 @@ public async Task PaymentInfo_Should_Handle_PaymentStatus_FullyPaid_CaseInsensit
// Assert
model.ShouldNotBeNull();
- model.TotalPaid.ShouldBe(2600m); // 1000 + 500 + 800 + 300 (all "Fully Paid" variations, excluding 200)
+ model.RequestedAmount.ShouldBe(15000);
+ model.RecommendedAmount.ShouldBe(12000);
+ model.ApprovedAmount.ShouldBe(10000);
+ model.ApplicationId.ShouldBe(appId);
+ model.ApplicationFormVersionId.ShouldBe(applicationFormVersionId);
+ model.ApplicantId.ShouldBe(applicantId);
}
[Fact]
- public async Task PaymentInfo_Should_Include_Submitted_WithNullPaymentStatus_InPending()
+ public async Task PaymentInfo_Should_Return_Empty_View_When_Feature_Disabled()
{
// Arrange
var appId = Guid.NewGuid();
var applicationFormVersionId = Guid.NewGuid();
- var applicantId = Guid.NewGuid();
-
- var applicationDto = new GrantApplicationDto
- {
- Id = appId,
- ApprovedAmount = 10000,
- Applicant = new GrantManager.GrantApplications.GrantApplicationApplicantDto { Id = applicantId }
- };
-
- var paymentRequests = new List
- {
- new() { Amount = 1000, Status = PaymentRequestStatus.Submitted, PaymentStatus = null, InvoiceStatus = null }, // Pending
- new() { Amount = 500, Status = PaymentRequestStatus.Submitted, PaymentStatus = null, InvoiceStatus = "SentToCas" }, // Pending
- new() { Amount = 800, Status = PaymentRequestStatus.Submitted, PaymentStatus = null, InvoiceStatus = "Validated" }, // Pending
- new() { Amount = 300, Status = PaymentRequestStatus.Submitted, PaymentStatus = null, InvoiceStatus = " " }, // Pending (whitespace)
- new() { Amount = 200, Status = PaymentRequestStatus.Submitted, PaymentStatus = "Fully Paid", InvoiceStatus = "Validated" }, // Not pending (paid)
- };
var appService = Substitute.For();
var paymentRequestService = Substitute.For();
var featureChecker = Substitute.For();
- var applicationLinksService = Substitute.For();
- appService.GetAsync(appId).Returns(applicationDto);
- paymentRequestService.GetListByApplicationIdAsync(appId).Returns(paymentRequests);
- applicationLinksService.GetListByApplicationAsync(appId).Returns([]);
- featureChecker.IsEnabledAsync("Unity.Payments").Returns(true);
+ featureChecker.IsEnabledAsync("Unity.Payments").Returns(false);
- var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker, applicationLinksService);
+ var viewComponent = CreateViewComponent(appService, paymentRequestService, featureChecker);
// Act
var result = await viewComponent.InvokeAsync(appId, applicationFormVersionId) as ViewViewComponentResult;
@@ -547,58 +267,17 @@ public async Task PaymentInfo_Should_Include_Submitted_WithNullPaymentStatus_InP
// Assert
model.ShouldNotBeNull();
- model.TotalPendingAmounts.ShouldBe(2600m); // 1000 + 500 + 800 + 300 (all with null PaymentStatus)
- model.TotalPaid.ShouldBe(200m); // Only the one with PaymentStatus = "Fully Paid"
- }
-
- [Fact]
- public async Task PaymentInfo_Should_Exclude_Submitted_WithError_InvoiceStatus_FromPending()
- {
- // Arrange
- var appId = Guid.NewGuid();
- var applicationFormVersionId = Guid.NewGuid();
- var applicantId = Guid.NewGuid();
+ model.TotalPaid.ShouldBeNull();
+ model.TotalPendingAmounts.ShouldBeNull();
+ model.RemainingAmount.ShouldBeNull();
- var applicationDto = new GrantApplicationDto
- {
- Id = appId,
- ApprovedAmount = 10000,
- Applicant = new GrantManager.GrantApplications.GrantApplicationApplicantDto { Id = applicantId }
- };
-
- var paymentRequests = new List
- {
- new() { Amount = 1000, Status = PaymentRequestStatus.Submitted, PaymentStatus = null, InvoiceStatus = "ErrorFromCas" }, // Not pending (error)
- new() { Amount = 500, Status = PaymentRequestStatus.Submitted, PaymentStatus = null, InvoiceStatus = " Error " }, // Not pending (error with spaces)
- new() { Amount = 800, Status = PaymentRequestStatus.Submitted, PaymentStatus = null, InvoiceStatus = "ServiceUnavailable" }, // Pending (will be retried)
- new() { Amount = 300, Status = PaymentRequestStatus.Submitted, PaymentStatus = null, InvoiceStatus = "SentToCas" }, // Pending (no error)
- new() { Amount = 200, Status = PaymentRequestStatus.L1Pending }, // Pending (L1)
- };
-
- var appService = Substitute.For