diff --git a/cypress/component/fineAccountEnquiry/accountEnquiry/AccountEnquiryConsolidatedAccounts.cy.ts b/cypress/component/fineAccountEnquiry/accountEnquiry/AccountEnquiryConsolidatedAccounts.cy.ts index 03edb51f66..d71f02f71f 100644 --- a/cypress/component/fineAccountEnquiry/accountEnquiry/AccountEnquiryConsolidatedAccounts.cy.ts +++ b/cypress/component/fineAccountEnquiry/accountEnquiry/AccountEnquiryConsolidatedAccounts.cy.ts @@ -1,48 +1,90 @@ -import 'cypress-axe'; import { AccountNavDetailsLocators } from '../../../shared/selectors/account-details/account.nav.details.locators'; -import { DEFENDANT_HEADER_MOCK } from './mocks/defendant_details_mock'; -import { OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_MOCK } from '@services/fines/opal-fines-service/mocks/opal-fines-account-defendant-account-party.mock'; -import { OPAL_FINES_ACCOUNT_DEFENDANT_AT_A_GLANCE_MOCK } from '@services/fines/opal-fines-service/mocks/opal-fines-account-defendant-at-a-glance.mock'; -import { USER_STATE_MOCK_PERMISSION_BU77 } from '../../CommonIntercepts/CommonUserState.mocks'; +import { OPAL_FINES_ACCOUNT_DEFENDANT_DETAILS_CONSOLIDATED_ACCOUNTS_MOCK } from '@services/fines/opal-fines-service/mocks/opal-fines-account-defendant-details-consolidated-accounts.mock'; import { interceptAuthenticatedUser, interceptUserState } from 'cypress/component/CommonIntercepts/CommonIntercepts'; -import { - interceptDefendantDetails, - interceptDefendantHeader, - interceptAtAGlance, -} from './intercept/defendantAccountIntercepts'; -import { setupAccountEnquiryComponent } from './setup/SetupComponent'; +import { USER_STATE_MOCK_PERMISSION_BU77 } from '../../CommonIntercepts/CommonUserState.mocks'; +import { ConsolidatedAccountsLocators } from '../../../shared/selectors/account-details/account.consolidated-accounts.locators'; +import { DEFENDANT_HEADER_MOCK } from './mocks/defendant_details_mock'; +import { interceptConsolidatedAccounts, interceptDefendantHeader } from './intercept/defendantAccountIntercepts'; import { IComponentProperties } from './setup/setupComponent.interface'; +import { setupAccountEnquiryComponent } from './setup/SetupComponent'; +import 'cypress-axe'; const ACCOUNT_ENQUIRY_JIRA_LABEL = '@JIRA-LABEL:account-enquiry'; + const buildTags = (...tags: string[]): string[] => [...tags, ACCOUNT_ENQUIRY_JIRA_LABEL, '@R1B']; -describe('Account Enquiry consolidated accounts tab', () => { - const componentProperties: IComponentProperties = { - accountId: '77', - fragments: 'defendant', - interceptedRoutes: ['/access-denied'], - }; +const componentProperties: IComponentProperties = { + accountId: '77', + fragments: 'consolidated-accounts', + interceptedRoutes: ['/access-denied'], +}; + +type ConsolidatedAccountsMock = typeof OPAL_FINES_ACCOUNT_DEFENDANT_DETAILS_CONSOLIDATED_ACCOUNTS_MOCK; - const setupShell = (hasConsolidatedAccounts: boolean) => { - const headerMock = structuredClone(DEFENDANT_HEADER_MOCK); - headerMock.has_consolidated_accounts = hasConsolidatedAccounts; - const accountId = headerMock.defendant_account_party_id; +const cell = (columnKey: string, rowIndex: number): string => ConsolidatedAccountsLocators.cell(columnKey, rowIndex); +const normaliseText = (value: string): string => + value + .replace(/\u00A0/g, ' ') + .replace(/\s+/g, ' ') + .trim(); - interceptAuthenticatedUser(); - interceptUserState(USER_STATE_MOCK_PERMISSION_BU77); - interceptDefendantHeader(accountId, headerMock, '123'); - interceptDefendantDetails(accountId, OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_MOCK, '123'); - interceptAtAGlance(accountId, OPAL_FINES_ACCOUNT_DEFENDANT_AT_A_GLANCE_MOCK, '123'); +const expectCellText = (selector: string, expected: string) => { + cy.get(selector) + .invoke('text') + .then((text) => { + expect(normaliseText(text)).to.eq(expected); + }); +}; + +const consolidatedAccountsMock: ConsolidatedAccountsMock = { + ...structuredClone(OPAL_FINES_ACCOUNT_DEFENDANT_DETAILS_CONSOLIDATED_ACCOUNTS_MOCK), + consolidated_accounts: [ + { + account_id: 99000000990002, + account_number: '99009902C', + date_imposed: '2025-01-12', + first_name: 'Casey', + imposed_by: 'Seed Child Court', + last_name: 'Child', + reference: 'LOCAL-CONSOL-CHILD', + }, + { + account_id: 99000000990003, + account_number: '99009903D', + date_imposed: '2026-02-03', + first_name: 'Robin', + imposed_by: 'Central London Magistrates Court', + last_name: 'Bridge', + reference: 'Ref/Mixed-Case/123', + }, + ], +}; - setupAccountEnquiryComponent({ ...componentProperties, accountId }); - cy.get(AccountNavDetailsLocators.root).should('exist'); +const setupConsolidatedAccountsScreen = ( + hasConsolidatedAccounts: boolean = true, + mockData: ConsolidatedAccountsMock = consolidatedAccountsMock, +) => { + const headerMock = { + ...structuredClone(DEFENDANT_HEADER_MOCK), + has_consolidated_accounts: hasConsolidatedAccounts, }; + const accountId = headerMock.defendant_account_party_id; + interceptAuthenticatedUser(); + interceptUserState(USER_STATE_MOCK_PERMISSION_BU77); + interceptDefendantHeader(accountId, headerMock, '123'); + interceptConsolidatedAccounts(accountId, mockData.consolidated_accounts, '123'); + + setupAccountEnquiryComponent({ ...componentProperties, accountId }); + cy.wait('@getConsolidatedAccounts').its('response.body').should('have.length', 2); +}; + +describe('Account Enquiry Consolidated Accounts Tab', () => { it( "AC1a. should display 'Consolidated accounts' to the right of 'History and notes' when consolidated accounts exist", { tags: [...buildTags('@JIRA-STORY:PO-2389'), '@JIRA-EPIC:PO-976'] }, () => { - setupShell(true); + setupConsolidatedAccountsScreen(true); cy.get(AccountNavDetailsLocators.subNav.historyAndNotesTab).should('exist'); cy.get(AccountNavDetailsLocators.subNav.consolidatedAccountsTab).should('exist'); @@ -54,9 +96,6 @@ describe('Account Enquiry consolidated accounts tab', () => { expect(labels).to.include('Consolidated accounts'); expect(labels.indexOf('History and notes')).to.be.lessThan(labels.indexOf('Consolidated accounts')); }); - - cy.injectAxe(); - cy.checkA11y(AccountNavDetailsLocators.subNav.root); }, ); @@ -64,13 +103,55 @@ describe('Account Enquiry consolidated accounts tab', () => { "AC2a. should not display 'Consolidated accounts' when no consolidated accounts exist", { tags: [...buildTags('@JIRA-STORY:PO-2389'), '@JIRA-EPIC:PO-976'] }, () => { - setupShell(false); + setupConsolidatedAccountsScreen(false); cy.get(AccountNavDetailsLocators.subNav.historyAndNotesTab).should('exist'); cy.get(AccountNavDetailsLocators.subNav.consolidatedAccountsTab).should('not.exist'); + }, + ); + + it( + 'AC1a, AC1b, AC1c, AC1d, AC1e, AC1f: fetches consolidated child accounts and renders the read-only table', + { + tags: [...buildTags('@JIRA-STORY:PO-2391'), '@JIRA-EPIC:PO-2332'], + }, + () => { + setupConsolidatedAccountsScreen(); + + cy.get(ConsolidatedAccountsLocators.tabLink) + .should('have.attr', 'aria-current', 'page') + .and('contain.text', 'Consolidated accounts'); + cy.get(ConsolidatedAccountsLocators.heading).should('contain.text', 'Consolidated accounts'); + cy.get(`${ConsolidatedAccountsLocators.tabRoot} th.govuk-table__header`).then((headers) => { + expect([...headers].map((header) => normaliseText(header.textContent ?? ''))).to.deep.eq([ + 'Account', + 'Name', + 'Date imposed', + 'Imposed by', + 'Reference', + ]); + }); + cy.get(ConsolidatedAccountsLocators.tableRows).should('have.length', 2); + cy.get(ConsolidatedAccountsLocators.tabRoot).within(() => { + cy.get('input, textarea, select, button, [contenteditable="true"]').should('not.exist'); + }); + + expectCellText(cell('name', 0), 'CHILD, Casey'); + expectCellText(cell('date-imposed', 0), '12 Jan 2025'); + expectCellText(cell('imposed-by', 0), 'Seed Child Court'); + expectCellText(cell('reference', 0), 'LOCAL-CONSOL-CHILD'); + expectCellText(cell('name', 1), 'BRIDGE, Robin'); + expectCellText(cell('date-imposed', 1), '03 Feb 2026'); + expectCellText(cell('imposed-by', 1), 'Central London Magistrates Court'); + expectCellText(cell('reference', 1), 'Ref/Mixed-Case/123'); - cy.injectAxe(); - cy.checkA11y(AccountNavDetailsLocators.subNav.root); + cy.get(cell('number', 0)) + .find('a') + .should(($link) => { + expect(normaliseText($link.text())).to.eq('99009902C'); + }) + .and('have.attr', 'href', '/fines/account/defendant/99000000990002/details#at-a-glance') + .and('have.attr', 'target', '_blank'); }, ); }); diff --git a/cypress/component/fineAccountEnquiry/accountEnquiry/intercept/defendantAccountIntercepts.ts b/cypress/component/fineAccountEnquiry/accountEnquiry/intercept/defendantAccountIntercepts.ts index 1dcf8d12b0..cf9b8b0ccb 100644 --- a/cypress/component/fineAccountEnquiry/accountEnquiry/intercept/defendantAccountIntercepts.ts +++ b/cypress/component/fineAccountEnquiry/accountEnquiry/intercept/defendantAccountIntercepts.ts @@ -5,6 +5,7 @@ import { IOpalFinesAccountDefendantDetailsFixedPenaltyTabRefData } from '@servic import { IOpalFinesAccountDefendantDetailsHistoryAndNotesTabRefData } from '@services/fines/opal-fines-service/interfaces/opal-fines-account-defendant-details-history-and-notes-tab-ref-data.interface'; import { IOpalFinesAccountDefendantDetailsImpositionsTabRefData } from '@services/fines/opal-fines-service/interfaces/opal-fines-account-defendant-details-impositions-tab-ref-data.interface'; import { IOpalFinesAccountDefendantDetailsPaymentTermsLatest } from '@services/fines/opal-fines-service/interfaces/opal-fines-account-defendant-details-payment-terms-latest.interface'; +import { IOpalFinesAccountDefendantDetailsConsolidatedAccount } from 'src/app/flows/fines/services/opal-fines-service/interfaces/opal-fines-account-defendant-account-consolidated-account.interface'; import { IOpalFinesAccountMinorCreditorAtAGlance } from 'src/app/flows/fines/services/opal-fines-service/interfaces/opal-fines-account-minor-creditor-at-a-glance.interface'; import { IOpalFinesAccountMinorCreditorCreditor } from 'src/app/flows/fines/services/opal-fines-service/interfaces/opal-fines-account-minor-creditor-creditor.interface'; import { IOpalFinesAccountMinorCreditorDetailsHistoryAndNotesTabRefData } from 'src/app/flows/fines/services/opal-fines-service/interfaces/opal-fines-account-minor-creditor-details-history-and-notes-tab-ref-data.interface'; @@ -29,6 +30,28 @@ export function interceptAddNotes() { .as('postAddNotes'); } +/** + * Intercepts the GET request to the defendant accounts "consolidated-accounts" endpoint and mocks the response. + * + * @param accountId - The unique identifier for the account. + * @param consolidatedAccounts - The consolidated accounts to be returned in the response body. + * @param respHeaderEtag - The value to set for the ETag response header. + * @returns Cypress chainable object with the intercepted request aliased as 'getConsolidatedAccounts'. + */ +export function interceptConsolidatedAccounts( + accountId: string | number, + consolidatedAccounts: IOpalFinesAccountDefendantDetailsConsolidatedAccount[], + respHeaderEtag: string, +) { + return cy + .intercept('GET', `**/defendant-accounts/${accountId}/consolidated-accounts`, { + statusCode: 200, + headers: { ETag: respHeaderEtag }, + body: consolidatedAccounts, + }) + .as('getConsolidatedAccounts'); +} + /** * Intercepts the GET request to the defendant accounts "at-a-glance" endpoint and mocks the response. * diff --git a/cypress/e2e/functional/opal/actions/account-details/details.consolidated-accounts.actions.ts b/cypress/e2e/functional/opal/actions/account-details/details.consolidated-accounts.actions.ts new file mode 100644 index 0000000000..28108c3e4d --- /dev/null +++ b/cypress/e2e/functional/opal/actions/account-details/details.consolidated-accounts.actions.ts @@ -0,0 +1,196 @@ +import { ConsolidatedAccountsLocators } from '../../../../../shared/selectors/account-details/account.consolidated-accounts.locators'; +import { createScopedLogger } from '../../../../../support/utils/log.helper'; + +const log = createScopedLogger('AccountDetailsConsolidatedAccountsActions'); + +const CHILD_ACCOUNT = { + account_id: 99000000990002, + account_number: '99009902C', + date_imposed: '2025-01-12', + first_name: 'Casey', + imposed_by: 'Seed Child Court', + last_name: 'Child', + reference: 'LOCAL-CONSOL-CHILD', +}; + +/** + * Actions for the Account Details Consolidated accounts tab. + */ +export class AccountDetailsConsolidatedAccountsActions { + private static readonly WAIT_MS = 15_000; + + /** + * Presents the supplied account as a master account with one consolidated child account. + * + * @param accountId - Current defendant account ID. + * @param header - Current account header payload. + */ + public stubMasterAccountWithChild(accountId: number, header: Record): void { + log('intercept', 'Presenting account as a master account with one consolidated child account', { accountId }); + + cy.intercept('GET', `**/defendant-accounts/${accountId}/header-summary`, { + statusCode: 200, + headers: { ETag: 'e2e-consolidated-header' }, + body: { ...header, has_consolidated_accounts: true }, + }).as('consolidatedHeaderSummary'); + + cy.intercept('GET', `**/defendant-accounts/${accountId}/consolidated-accounts`, { + statusCode: 200, + headers: { ETag: 'e2e-consolidated-accounts' }, + body: [CHILD_ACCOUNT], + }).as('consolidatedAccounts'); + + cy.intercept('GET', `**/defendant-accounts/${CHILD_ACCOUNT.account_id}/header-summary`, { + statusCode: 200, + headers: { ETag: 'e2e-consolidated-child-header' }, + body: { + ...header, + account_number: CHILD_ACCOUNT.account_number, + defendant_account_id: String(CHILD_ACCOUNT.account_id), + defendant_account_party_id: String(CHILD_ACCOUNT.account_id), + has_consolidated_accounts: false, + party_details: { + party_id: String(CHILD_ACCOUNT.account_id), + organisation_flag: false, + organisation_details: null, + individual_details: { + title: null, + forenames: CHILD_ACCOUNT.first_name, + surname: CHILD_ACCOUNT.last_name, + date_of_birth: null, + age: null, + national_insurance_number: null, + individual_aliases: null, + }, + }, + }, + }).as('consolidatedChildHeaderSummary'); + + cy.intercept('GET', `**/defendant-accounts/${CHILD_ACCOUNT.account_id}/at-a-glance`, { + statusCode: 200, + headers: { ETag: 'e2e-consolidated-child-at-a-glance' }, + body: { + version: null, + defendant_account_id: String(CHILD_ACCOUNT.account_id), + account_number: CHILD_ACCOUNT.account_number, + debtor_type: 'PERSON', + is_youth: false, + party_details: { + party_id: String(CHILD_ACCOUNT.account_id), + organisation_flag: false, + organisation_details: null, + individual_details: { + title: null, + forenames: CHILD_ACCOUNT.first_name, + surname: CHILD_ACCOUNT.last_name, + date_of_birth: null, + national_insurance_number: null, + individual_aliases: null, + }, + }, + address: { + address_line_1: '1 Child Street', + address_line_2: null, + address_line_3: null, + address_line_4: null, + address_line_5: null, + postcode: 'CH1 1LD', + }, + language_preferences: null, + payment_terms: null, + enforcement_status: { + last_enforcement_action: null, + collection_order_made: false, + default_days_in_jail: 0, + enforcement_override: null, + last_movement_date: null, + }, + comments_and_notes: null, + }, + }).as('consolidatedChildAtAGlance'); + } + + /** + * Asserts the consolidated accounts table is visible and contains child account rows. + */ + public assertChildAccountsTableVisible(): void { + log('assert', 'Checking consolidated accounts table is visible'); + + cy.get(ConsolidatedAccountsLocators.tabRoot, { + timeout: AccountDetailsConsolidatedAccountsActions.WAIT_MS, + }).should('be.visible'); + cy.get(ConsolidatedAccountsLocators.tableRows, { + timeout: AccountDetailsConsolidatedAccountsActions.WAIT_MS, + }).should('have.length.at.least', 1); + cy.get(ConsolidatedAccountsLocators.firstChildAccountLink(0)) + .should('be.visible') + .and('contain.text', CHILD_ACCOUNT.account_number); + } + + /** + * Opens the Consolidated accounts tab and waits for its table payload. + */ + public openTab(): void { + log('navigate', 'Opening Consolidated accounts tab'); + + cy.get(ConsolidatedAccountsLocators.tabLink, { + timeout: AccountDetailsConsolidatedAccountsActions.WAIT_MS, + }) + .should('be.visible') + .click(); + + cy.get(ConsolidatedAccountsLocators.activeTabLink) + .should('be.visible') + .and('contain.text', 'Consolidated accounts'); + cy.wait('@consolidatedAccounts', { timeout: AccountDetailsConsolidatedAccountsActions.WAIT_MS }) + .its('response.statusCode') + .should('eq', 200); + cy.get(ConsolidatedAccountsLocators.tabRoot, { + timeout: AccountDetailsConsolidatedAccountsActions.WAIT_MS, + }).should('be.visible'); + } + + /** + * Clicks the first child account link and asserts it routes to the At a glance tab. + */ + public openFirstChildAtAGlance(): void { + log('open', 'Opening first consolidated child account at At a glance'); + + cy.get(ConsolidatedAccountsLocators.firstChildAccountLink(0), { + timeout: AccountDetailsConsolidatedAccountsActions.WAIT_MS, + }) + .should('be.visible') + .and('contain.text', CHILD_ACCOUNT.account_number) + .and(($link) => { + expect($link.attr('href')).to.eq(`/fines/account/defendant/${CHILD_ACCOUNT.account_id}/details#at-a-glance`); + }) + .invoke('removeAttr', 'target') + .click(); + + cy.location('pathname', { timeout: AccountDetailsConsolidatedAccountsActions.WAIT_MS }).should( + 'eq', + `/fines/account/defendant/${CHILD_ACCOUNT.account_id}/details`, + ); + cy.location('hash', { timeout: AccountDetailsConsolidatedAccountsActions.WAIT_MS }).should('eq', '#at-a-glance'); + cy.wait('@consolidatedChildHeaderSummary', { timeout: AccountDetailsConsolidatedAccountsActions.WAIT_MS }) + .its('response.statusCode') + .should('eq', 200); + cy.wait('@consolidatedChildAtAGlance', { timeout: AccountDetailsConsolidatedAccountsActions.WAIT_MS }) + .its('response.statusCode') + .should('eq', 200); + cy.get(ConsolidatedAccountsLocators.headerCaption, { timeout: AccountDetailsConsolidatedAccountsActions.WAIT_MS }) + .should('be.visible') + .and('contain.text', CHILD_ACCOUNT.account_number); + } + + /** + * Asserts the selected child account details are shown for the opened record. + */ + public assertSelectedChildAccountDetailsVisible(): void { + log('assert', 'Checking selected child account details are visible'); + + cy.get(ConsolidatedAccountsLocators.headerCaption, { timeout: AccountDetailsConsolidatedAccountsActions.WAIT_MS }) + .should('be.visible') + .and('contain.text', CHILD_ACCOUNT.account_number); + } +} diff --git a/cypress/e2e/functional/opal/features/release1b/fineAccountEnquiry/defendant/adult-youth/AccountEnquiriesConsolidation.feature b/cypress/e2e/functional/opal/features/release1b/fineAccountEnquiry/defendant/adult-youth/AccountEnquiriesConsolidation.feature new file mode 100644 index 0000000000..8ada6d45c6 --- /dev/null +++ b/cypress/e2e/functional/opal/features/release1b/fineAccountEnquiry/defendant/adult-youth/AccountEnquiriesConsolidation.feature @@ -0,0 +1,26 @@ +@JIRA-LABEL:account-enquiry +Feature: Defendant - Adult or youth - Account Enquiries - Consolidation + As an Opal user + I want to view the child account of a defendant's consolidated account + + Background: + Given I am logged in with email "opal-test@dev.platform.hmcts.net" + Given I create a "adultOrYouthOnly" draft account with the following details and set status "Publishing Pending" using user "opal-test-10@dev.platform.hmcts.net": + | Account_status | Submitted | + | account.defendant.title | Ms | + | account.defendant.forenames | Harriet | + | account.defendant.surname | ConsolidatedAccount{uniq} | + | account.defendant.email_address_1 | Harriet.ConsolidatedAccount{uniq}@test.com | + | account.defendant.dob | 2002-05-15 | + When I search for the account by last name "ConsolidatedAccount{uniq}" and open the latest result + + + @R1B @JIRA-STORY:PO-2391 @JIRA-EPIC:PO-2332 + Scenario: Consolidated account links open the child account At a glance view + # The consolidated-account response is mocked in the test harness because consolidation + # behaviour is not available end-to-end in the live environment yet. + Given I am viewing a master account with a consolidated child account + When I view the Consolidated accounts tab + Then I can see a table containing the child accounts + When I view the first child account record + Then I am presented with the details of the selected child account \ No newline at end of file diff --git a/cypress/e2e/functional/opal/features/release1b/fineAccountEnquiry/defendant/adult-youth/AccountEnquiriesConsolidationAccessibility.feature b/cypress/e2e/functional/opal/features/release1b/fineAccountEnquiry/defendant/adult-youth/AccountEnquiriesConsolidationAccessibility.feature new file mode 100644 index 0000000000..a95ff0983d --- /dev/null +++ b/cypress/e2e/functional/opal/features/release1b/fineAccountEnquiry/defendant/adult-youth/AccountEnquiriesConsolidationAccessibility.feature @@ -0,0 +1,24 @@ +@JIRA-LABEL:account-enquiry +Feature: Defendant - Adult or youth - Account Enquiries - Consolidation Accessibility + As an Opal user + I want to view the child account of a defendant's consolidated account + + Background: + Given I am logged in with email "opal-test@dev.platform.hmcts.net" + Given I create a "adultOrYouthOnly" draft account with the following details and set status "Publishing Pending" using user "opal-test-10@dev.platform.hmcts.net": + | Account_status | Submitted | + | account.defendant.title | Ms | + | account.defendant.forenames | Harriet | + | account.defendant.surname | ConsolidatedAccount{uniq} | + | account.defendant.email_address_1 | Harriet.ConsolidatedAccount{uniq}@test.com | + | account.defendant.dob | 2002-05-15 | + When I search for the account by last name "ConsolidatedAccount{uniq}" and open the latest result + + @R1B @JIRA-STORY:PO-2391 @JIRA-EPIC:PO-2332 + Scenario: Consolidated account tab accessibility + # The consolidated-account response is mocked in the test harness because consolidation + # behaviour is not available end-to-end in the live environment yet. + Given I am viewing a master account with a consolidated child account + When I view the Consolidated accounts tab + And I can see a table containing the child accounts + Then I check the page for accessibility diff --git a/cypress/e2e/functional/opal/flows/account-enquiry.flow.ts b/cypress/e2e/functional/opal/flows/account-enquiry.flow.ts index e03a09a8f6..232e121662 100644 --- a/cypress/e2e/functional/opal/flows/account-enquiry.flow.ts +++ b/cypress/e2e/functional/opal/flows/account-enquiry.flow.ts @@ -13,6 +13,7 @@ import { AccountDetailsMinorCreditorActions } from '../actions/account-details/d import { AccountDetailsPaymentTermsActions } from '../actions/account-details/details.payment-terms.actions'; import { AccountDetailsFixedPenaltyActions } from '../actions/account-details/details.fixed-penalty.actions'; import { AccountDetailsHistoryActions } from '../actions/account-details/details.history.actions'; +import { AccountDetailsConsolidatedAccountsActions } from '../actions/account-details/details.consolidated-accounts.actions'; import { AccountSearchIndividualsLocators as L } from '../../../../shared/selectors/account-search/account.search.individuals.locators'; import { AccountSearchCompaniesLocators as C } from '../../../../shared/selectors/account-search/account.search.companies.locators'; import { ForceSingleTabNavigation } from '../../../../support/utils/navigation'; @@ -95,6 +96,7 @@ export class AccountEnquiryFlow { private readonly enforcement = new AccountDetailsEnforcementActions(); private readonly removeParentGuardian = new RemoveParentGuardianActions(); private readonly historyAndNotes = new AccountDetailsHistoryActions(); + private readonly consolidatedAccounts = new AccountDetailsConsolidatedAccountsActions(); /** * Ensures the test is on the Individuals Account Search page. @@ -457,6 +459,80 @@ export class AccountEnquiryFlow { this.historyAndNotes.assertHistoryAndNotesTabLoaded(); } + /** + * Presents the current account as a master account with one consolidated child account. + */ + public prepareMasterAccountWithConsolidatedChildAccount(): void { + logAE('method', 'prepareMasterAccountWithConsolidatedChildAccount()'); + + this.extractDefendantAccountIdFromUrl().then((accountId) => { + this.fetchHeaderSummary(accountId).then((header) => { + this.consolidatedAccounts.stubMasterAccountWithChild(accountId, header); + cy.reload(); + cy.wait('@consolidatedHeaderSummary', { timeout: AccountEnquiryFlow.WAIT_MS }) + .its('response.statusCode') + .should('eq', 200); + }); + }); + } + + /** + * Asserts the consolidated accounts table is visible. + */ + public assertConsolidatedAccountsTableVisible(): void { + logAE('method', 'assertConsolidatedAccountsTableVisible()'); + this.consolidatedAccounts.assertChildAccountsTableVisible(); + } + + /** + * Navigates to the Consolidated accounts tab and asserts it has loaded. + */ + public goToConsolidatedAccountsTab(): void { + logAE('method', 'goToConsolidatedAccountsTab()'); + this.consolidatedAccounts.openTab(); + } + + /** + * Opens the first Consolidated accounts child account link and asserts the At a glance route. + */ + public openFirstConsolidatedAccountLinkAtAGlance(): void { + logAE('method', 'openFirstConsolidatedAccountLinkAtAGlance()'); + this.consolidatedAccounts.openFirstChildAtAGlance(); + this.detailsNav.assertAtAGlanceTabIsActive(); + } + + /** + * Asserts the selected child account details are displayed. + */ + public assertSelectedChildAccountDetailsVisible(): void { + logAE('method', 'assertSelectedChildAccountDetailsVisible()'); + this.consolidatedAccounts.assertSelectedChildAccountDetailsVisible(); + } + + /** + * Asserts the initial History and notes rows have been rendered. + */ + public assertHistoryAndNotesItemsLoaded(): void { + logAE('method', 'assertHistoryAndNotesItemsLoaded()'); + this.historyAndNotes.assertHistoryAndNotesRowsLoaded(2); + } + + /** + * Applies the Notes filter to the History and notes tab. + */ + public filterHistoryAndNotesToNotes(): void { + logAE('method', 'filterHistoryAndNotesToNotes()'); + this.historyAndNotes.applyNotesFilter(); + } + + /** + * Asserts the History and notes table only shows Note rows after filtering. + */ + public assertHistoryAndNotesFilteredToNotes(): void { + logAE('method', 'assertHistoryAndNotesFilteredToNotes()'); + this.historyAndNotes.assertHistoryAndNotesFilteredToNotes(); + } + /** * Opens the first account link from History and notes and asserts the new-tab target. */ diff --git a/cypress/shared/selectors/account-details/account.consolidated-accounts.locators.ts b/cypress/shared/selectors/account-details/account.consolidated-accounts.locators.ts new file mode 100644 index 0000000000..f38a75beed --- /dev/null +++ b/cypress/shared/selectors/account-details/account.consolidated-accounts.locators.ts @@ -0,0 +1,10 @@ +export const ConsolidatedAccountsLocators = { + tabRoot: 'app-fines-acc-defendant-details-consolidated-accounts-tab', + tabLink: 'li[subnavitemid="consolidated-accounts-tab"] > a.moj-sub-navigation__link', + activeTabLink: 'a.moj-sub-navigation__link[aria-current="page"]', + headerCaption: 'opal-lib-govuk-heading-with-caption .govuk-caption-l', + tableRows: 'app-fines-acc-defendant-details-consolidated-accounts-tab tbody tr.govuk-table__row', + heading: 'app-fines-acc-defendant-details-consolidated-accounts-tab h2', + cell: (columnKey: string, rowIndex: number): string => `#consolidated-account-${columnKey}-${rowIndex}`, + firstChildAccountLink: (rowIndex: number): string => `#consolidated-account-number-${rowIndex} a`, +} as const; diff --git a/cypress/support/step_definitions/searchForAccount/account-enquiry.steps.ts b/cypress/support/step_definitions/searchForAccount/account-enquiry.steps.ts index ac0ff951cc..e3607856d6 100644 --- a/cypress/support/step_definitions/searchForAccount/account-enquiry.steps.ts +++ b/cypress/support/step_definitions/searchForAccount/account-enquiry.steps.ts @@ -474,6 +474,14 @@ Given('the History and notes API is stubbed with standard tab data', () => { accountEnquiryFlow().stubHistoryAndNotesTabData(); }); +/** + * @step Presents the current account as a master account with a consolidated child account. + */ +Given('I am viewing a master account with a consolidated child account', () => { + log('step', 'Prepare master account with consolidated child account'); + accountEnquiryFlow().prepareMasterAccountWithConsolidatedChildAccount(); +}); + /** * @step Navigates to the History and notes tab. */ @@ -482,6 +490,38 @@ When('I go to the History and notes tab', () => { accountEnquiryFlow().goToHistoryAndNotesTab(); }); +/** + * @step Views the Consolidated accounts tab. + */ +When('I view the Consolidated accounts tab', () => { + log('step', 'View Consolidated accounts tab'); + accountEnquiryFlow().goToConsolidatedAccountsTab(); +}); + +/** + * @step Verifies the consolidated accounts table shows child account rows. + */ +Then('I can see a table containing the child accounts', () => { + log('assert', 'Consolidated accounts table contains child account rows'); + accountEnquiryFlow().assertConsolidatedAccountsTableVisible(); +}); + +/** + * @step Views the first child account record from the consolidated accounts table. + */ +When('I view the first child account record', () => { + log('step', 'View first child account record'); + accountEnquiryFlow().openFirstConsolidatedAccountLinkAtAGlance(); +}); + +/** + * @step Verifies the selected child account details are displayed. + */ +Then('I am presented with the details of the selected child account', () => { + log('assert', 'Selected child account details are displayed'); + accountEnquiryFlow().assertSelectedChildAccountDetailsVisible(); +}); + /** * @step Verifies History and notes rows loaded. */ diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/constants/fines-acc-defendant-account-tabs-cache-map.constant.ts b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/constants/fines-acc-defendant-account-tabs-cache-map.constant.ts index 749278c162..11424a4b7d 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/constants/fines-acc-defendant-account-tabs-cache-map.constant.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/constants/fines-acc-defendant-account-tabs-cache-map.constant.ts @@ -9,4 +9,5 @@ export const FINES_ACC_DEFENDANT_ACCOUNT_TABS_CACHE_MAP: IFinesAccDefendantAccou enforcement: 'defendantAccountEnforcementCache$', impositions: 'defendantAccountImpositionsCache$', 'history-and-notes': 'defendantAccountHistoryAndNotesCache$', + 'consolidated-accounts': 'defendantAccountConsolidatedAccountsCache$', }; diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-consolidated-accounts-tab/fines-acc-defendant-details-consolidated-accounts-tab.component.html b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-consolidated-accounts-tab/fines-acc-defendant-details-consolidated-accounts-tab.component.html new file mode 100644 index 0000000000..39bdd74ddd --- /dev/null +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-consolidated-accounts-tab/fines-acc-defendant-details-consolidated-accounts-tab.component.html @@ -0,0 +1,53 @@ +
+

Consolidated accounts

+
+ + @if (tabData.consolidated_accounts.length) { + + + + + + + + + + + + + @for (account of tabData.consolidated_accounts; track account.account_id; let rowIndex = $index) { + + + + + + + + } + +
AccountNameDate imposedImposed byReference
+ + {{ account.account_number }} + + + {{ account.last_name | uppercase }}, {{ account.first_name }} + + {{ account.date_imposed | dateFormat: DATE_INPUT_FORMAT : DATE_OUTPUT_FORMAT }} + + {{ account.imposed_by }} + + {{ account.reference }} +
+
+ } @else { +

There are no consolidated accounts for this account.

+ } +
diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-consolidated-accounts-tab/fines-acc-defendant-details-consolidated-accounts-tab.component.spec.ts b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-consolidated-accounts-tab/fines-acc-defendant-details-consolidated-accounts-tab.component.spec.ts new file mode 100644 index 0000000000..700dd16216 --- /dev/null +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-consolidated-accounts-tab/fines-acc-defendant-details-consolidated-accounts-tab.component.spec.ts @@ -0,0 +1,74 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { OPAL_FINES_ACCOUNT_DEFENDANT_DETAILS_CONSOLIDATED_ACCOUNTS_MOCK } from '@services/fines/opal-fines-service/mocks/opal-fines-account-defendant-details-consolidated-accounts.mock'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { FinesAccDefendantDetailsConsolidatedAccountsTabComponent } from './fines-acc-defendant-details-consolidated-accounts-tab.component'; + +describe('FinesAccDefendantDetailsConsolidatedAccountsTabComponent', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [FinesAccDefendantDetailsConsolidatedAccountsTabComponent], + providers: [provideRouter([])], + }).compileComponents(); + }); + + const setupComponent = ( + tabData = structuredClone(OPAL_FINES_ACCOUNT_DEFENDANT_DETAILS_CONSOLIDATED_ACCOUNTS_MOCK), + ): { + component: FinesAccDefendantDetailsConsolidatedAccountsTabComponent; + fixture: ComponentFixture; + } => { + const fixture = TestBed.createComponent(FinesAccDefendantDetailsConsolidatedAccountsTabComponent); + const component = fixture.componentInstance; + + fixture.componentRef.setInput('tabData', tabData); + fixture.detectChanges(); + + return { component, fixture }; + }; + + it('should create', () => { + const { component } = setupComponent(); + + expect(component).toBeTruthy(); + }); + + it('should render consolidated account rows', () => { + const { fixture } = setupComponent(); + const textContent = fixture.nativeElement.textContent; + + expect(fixture.nativeElement.querySelector('table.govuk-table')).toBeTruthy(); + expect(textContent).toContain('99009902C'); + expect(textContent).toContain('21 Jan 2026'); + expect(textContent).toContain('Seed Child Court'); + expect(textContent).toContain('LOCAL-CONSOL-CHILD'); + }); + + it('should render account number as a link to the child account at-a-glance view', () => { + const { fixture } = setupComponent(); + const link = fixture.nativeElement.querySelector('#consolidated-account-number-0 a') as HTMLAnchorElement; + + expect(link).toBeTruthy(); + expect(link.textContent).toContain('99009902C'); + expect(link.getAttribute('href')).toBe('/fines/account/defendant/99000000990002/details#at-a-glance'); + }); + + it('should right-align the date imposed column', () => { + const { fixture } = setupComponent(); + const dateCell = fixture.nativeElement.querySelector( + '#consolidated-account-date-imposed-0', + ) as HTMLTableCellElement; + + expect(dateCell.classList).toContain('govuk-table__cell--numeric'); + }); + + it('should render an empty state when there are no consolidated accounts', () => { + const tabData = structuredClone(OPAL_FINES_ACCOUNT_DEFENDANT_DETAILS_CONSOLIDATED_ACCOUNTS_MOCK); + tabData.consolidated_accounts = []; + + const { fixture } = setupComponent(tabData); + + expect(fixture.nativeElement.querySelector('table.govuk-table')).toBeNull(); + expect(fixture.nativeElement.textContent).toContain('There are no consolidated accounts for this account.'); + }); +}); diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-consolidated-accounts-tab/fines-acc-defendant-details-consolidated-accounts-tab.component.ts b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-consolidated-accounts-tab/fines-acc-defendant-details-consolidated-accounts-tab.component.ts new file mode 100644 index 0000000000..75db9e5095 --- /dev/null +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-consolidated-accounts-tab/fines-acc-defendant-details-consolidated-accounts-tab.component.ts @@ -0,0 +1,42 @@ +import { UpperCasePipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { CustomHorizontalScrollPaneComponent } from '@hmcts/opal-frontend-common/components/custom/custom-horizontal-scroll-pane'; +import { DateFormatPipe } from '@hmcts/opal-frontend-common/pipes/date-format'; +import { FINES_ROUTING_PATHS } from '@routing/fines/constants/fines-routing-paths.constant'; +import { IOpalFinesAccountDefendantDetailsConsolidatedAccounts } from '@services/fines/opal-fines-service/interfaces/opal-fines-account-defendant-account-consolidated-accounts.interface'; +import { FINES_ACC_SUMMARY_TABS_CONTENT_STYLES } from '../../constants/fines-acc-summary-tabs-content-styles.constant'; +import { FINES_ACC_DEFENDANT_ROUTING_PATHS } from '../../routing/constants/fines-acc-defendant-routing-paths.constant'; +import { FINES_ACC_ROUTING_PATHS } from '../../routing/constants/fines-acc-routing-paths.constant'; +import { IFinesAccSummaryTabsContentStyles } from '../interfaces/fines-acc-summary-tabs-content-styles.interface'; + +@Component({ + selector: 'app-fines-acc-defendant-details-consolidated-accounts-tab', + imports: [CustomHorizontalScrollPaneComponent, DateFormatPipe, RouterLink, UpperCasePipe], + templateUrl: './fines-acc-defendant-details-consolidated-accounts-tab.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FinesAccDefendantDetailsConsolidatedAccountsTabComponent { + protected readonly DATE_INPUT_FORMAT = 'yyyy-MM-dd'; + protected readonly DATE_OUTPUT_FORMAT = 'dd MMM yyyy'; + + @Input({ required: true }) public tabData!: IOpalFinesAccountDefendantDetailsConsolidatedAccounts; + @Input() public style: IFinesAccSummaryTabsContentStyles = FINES_ACC_SUMMARY_TABS_CONTENT_STYLES; + + /** + * Builds the route to a child account's at-a-glance view. + * + * @param accountId - The child defendant account id. + * @returns The router link commands for the defendant account details page. + */ + public getAccountRouterLink(accountId: number): Array { + return [ + '/', + FINES_ROUTING_PATHS.root, + FINES_ACC_ROUTING_PATHS.root, + FINES_ACC_DEFENDANT_ROUTING_PATHS.root, + accountId, + FINES_ACC_DEFENDANT_ROUTING_PATHS.children.details, + ]; + } +} diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.html b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.html index c2d69c80df..5853b171cd 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.html +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.html @@ -232,4 +232,12 @@

Business Unit:

[style]="tabContentStyles" > } + @case ('consolidated-accounts') { + @if (tabConsolidatedAccounts$ | async; as tabData) { + + } + } } diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.spec.ts b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.spec.ts index ef566549de..e57a2fcc06 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.spec.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.spec.ts @@ -21,6 +21,8 @@ import { FINES_ACC_DEFENDANT_ROUTING_PATHS } from '../routing/constants/fines-ac import { OPAL_FINES_ACCOUNT_DEFENDANT_DETAILS_PARENT_OR_GUARDIAN_TAB_REF_DATA_MOCK } from '@services/fines/opal-fines-service/mocks/opal-fines-account-defendant-details-parent-or-guardian-tab-ref-data.mock'; import { OPAL_FINES_ACCOUNT_DEFENDANT_DETAILS_FIXED_PENALTY_MOCK } from '@services/fines/opal-fines-service/mocks/opal-fines-account-defendant-details-fixed-penalty.mock'; import { OPAL_FINES_RESULT_REF_DATA_MOCK } from '@services/fines/opal-fines-service/mocks/opal-fines-result-ref-data.mock'; +import { OPAL_FINES_ACCOUNT_DEFENDANT_DETAILS_CONSOLIDATED_ACCOUNTS_MOCK } from '@services/fines/opal-fines-service/mocks/opal-fines-account-defendant-details-consolidated-accounts.mock'; +import { FINES_ACC_DEFENDANT_ACCOUNT_TABS_CACHE_MAP } from './constants/fines-acc-defendant-account-tabs-cache-map.constant'; import { beforeEach, describe, expect, it, vi } from 'vitest'; describe('FinesAccDefendantDetailsComponent', () => { @@ -77,6 +79,7 @@ describe('FinesAccDefendantDetailsComponent', () => { clearCache: vi.fn().mockName('OpalFines.clearCache'), getResult: vi.fn().mockName('OpalFines.getResult'), getDefendantAccountFixedPenalty: vi.fn().mockName('OpalFines.getDefendantAccountFixedPenalty'), + getDefendantAccountConsolidatedAccounts: vi.fn().mockName('OpalFines.getDefendantAccountConsolidatedAccounts'), }; mockOpalFinesService.getDefendantAccountHeadingData.mockReturnValue(of(FINES_ACC_DEFENDANT_DETAILS_HEADER_MOCK)); mockOpalFinesService.getDefendantAccountAtAGlance.mockReturnValue( @@ -101,6 +104,9 @@ describe('FinesAccDefendantDetailsComponent', () => { mockOpalFinesService.getDefendantAccountImpositionsTabData.mockReturnValue( of(OPAL_FINES_ACCOUNT_DEFENDANT_DETAILS_IMPOSITIONS_TAB_REF_DATA_MOCK), ); + mockOpalFinesService.getDefendantAccountConsolidatedAccounts.mockReturnValue( + of(OPAL_FINES_ACCOUNT_DEFENDANT_DETAILS_CONSOLIDATED_ACCOUNTS_MOCK), + ); mockOpalFinesService.getResult.mockReturnValue(of(OPAL_FINES_RESULT_REF_DATA_MOCK)); await TestBed.configureTestingModule({ @@ -317,6 +323,22 @@ describe('FinesAccDefendantDetailsComponent', () => { ); }); + it('should fetch the consolidated accounts tab data when fragment is changed to consolidated-accounts', () => { + component['refreshFragment$'].next('consolidated-accounts'); + component.tabConsolidatedAccounts$.subscribe(); + + expect(mockOpalFinesService.getDefendantAccountConsolidatedAccounts).toHaveBeenCalledWith( + MOCK_FINES_ACCOUNT_STATE.account_id, + ); + expect(mockPayloadService.transformPayload).toHaveBeenCalled(); + }); + + it('should map the consolidated accounts tab to its service cache key', () => { + expect(FINES_ACC_DEFENDANT_ACCOUNT_TABS_CACHE_MAP['consolidated-accounts']).toBe( + 'defendantAccountConsolidatedAccountsCache$', + ); + }); + it('should refresh the data for the header and current tab when refreshPage is called', () => { component.accountStore.setAccountState(MOCK_FINES_ACCOUNT_STATE); component.refreshPage(); diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.ts b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.ts index dae6ead7d4..db8e055df7 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.ts @@ -56,6 +56,8 @@ import { AbstractAccountSummaryBaseComponent } from '@hmcts/opal-frontend-common import { IOpalFinesVersion } from '../../services/opal-fines-service/interfaces/opal-fines-version.interface'; import { FINES_ACC_BANNER_MESSAGES } from '../stores/constants/fines-acc-store-banner-messages.constant'; import { FinesAccDefendantDetailsHistoryAndNotesTabComponent } from './fines-acc-defendant-details-history-and-notes-tab/fines-acc-defendant-details-history-and-notes-tab.component'; +import { IOpalFinesAccountDefendantDetailsConsolidatedAccounts } from '@services/fines/opal-fines-service/interfaces/opal-fines-account-defendant-account-consolidated-accounts.interface'; +import { FinesAccDefendantDetailsConsolidatedAccountsTabComponent } from './fines-acc-defendant-details-consolidated-accounts-tab/fines-acc-defendant-details-consolidated-accounts-tab.component'; @Component({ selector: 'app-fines-acc-defendant-details', @@ -83,6 +85,7 @@ import { FinesAccDefendantDetailsHistoryAndNotesTabComponent } from './fines-acc MonetaryPipe, FinesAccSummaryHeaderComponent, FinesAccDefendantDetailsHistoryAndNotesTabComponent, + FinesAccDefendantDetailsConsolidatedAccountsTabComponent, ], templateUrl: './fines-acc-defendant-details.component.html', changeDetection: ChangeDetectionStrategy.OnPush, @@ -106,6 +109,7 @@ export class FinesAccDefendantDetailsComponent public tabImpositions$: Observable = EMPTY; public tabHistoryAndNotes$: Observable = EMPTY; public tabFixedPenalty$: Observable = EMPTY; + public tabConsolidatedAccounts$: Observable = EMPTY; public debtorTypes = FINES_ACC_DEBTOR_TYPES; public accountTypes = FINES_ACCOUNT_TYPES; public lastEnforcement: IOpalFinesResultRefData | null = null; @@ -189,6 +193,11 @@ export class FinesAccDefendantDetailsComponent this.opalFinesService.getDefendantAccountHistoryAndNotesTabData(account_id), ); break; + case 'consolidated-accounts': + this.tabConsolidatedAccounts$ = this.fetchTabDataTyped( + this.opalFinesService.getDefendantAccountConsolidatedAccounts(account_id), + ); + break; } }); } diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/interfaces/fines-acc-defendant-account-tabs-cache-map.interface.ts b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/interfaces/fines-acc-defendant-account-tabs-cache-map.interface.ts index 4c683a595f..74a585b470 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/interfaces/fines-acc-defendant-account-tabs-cache-map.interface.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/interfaces/fines-acc-defendant-account-tabs-cache-map.interface.ts @@ -7,4 +7,5 @@ export interface IFinesAccDefendantAccountTabsCacheMap { enforcement: 'defendantAccountEnforcementCache$'; impositions: 'defendantAccountImpositionsCache$'; 'history-and-notes': 'defendantAccountHistoryAndNotesCache$'; + 'consolidated-accounts': 'defendantAccountConsolidatedAccountsCache$'; } diff --git a/src/app/flows/fines/services/opal-fines-service/constants/opal-fines-cache-defaults.constant.ts b/src/app/flows/fines/services/opal-fines-service/constants/opal-fines-cache-defaults.constant.ts index a751a05ffe..6237b89c8a 100644 --- a/src/app/flows/fines/services/opal-fines-service/constants/opal-fines-cache-defaults.constant.ts +++ b/src/app/flows/fines/services/opal-fines-service/constants/opal-fines-cache-defaults.constant.ts @@ -20,6 +20,7 @@ export const OPAL_FINES_CACHE_DEFAULTS: IOpalFinesCache = { defendantAccountHistoryAndNotesCache$: null, defendantAccountPaymentTermsLatestCache$: null, defendantAccountFixedPenaltyCache$: null, + defendantAccountConsolidatedAccountsCache$: null, minorCreditorAccountAtAGlanceCache$: null, minorCreditorAccountCreditorCache$: null, majorCreditorAccountAtAGlanceCache$: null, diff --git a/src/app/flows/fines/services/opal-fines-service/interfaces/opal-fines-account-defendant-account-consolidated-account.interface.ts b/src/app/flows/fines/services/opal-fines-service/interfaces/opal-fines-account-defendant-account-consolidated-account.interface.ts new file mode 100644 index 0000000000..137879c0df --- /dev/null +++ b/src/app/flows/fines/services/opal-fines-service/interfaces/opal-fines-account-defendant-account-consolidated-account.interface.ts @@ -0,0 +1,9 @@ +export interface IOpalFinesAccountDefendantDetailsConsolidatedAccount { + account_id: number; + account_number: string; + date_imposed: string; + first_name: string; + imposed_by: string; + last_name: string; + reference: string; +} diff --git a/src/app/flows/fines/services/opal-fines-service/interfaces/opal-fines-account-defendant-account-consolidated-accounts.interface.ts b/src/app/flows/fines/services/opal-fines-service/interfaces/opal-fines-account-defendant-account-consolidated-accounts.interface.ts new file mode 100644 index 0000000000..9b731f9840 --- /dev/null +++ b/src/app/flows/fines/services/opal-fines-service/interfaces/opal-fines-account-defendant-account-consolidated-accounts.interface.ts @@ -0,0 +1,6 @@ +import { IOpalFinesAccountDefendantDetailsConsolidatedAccount } from './opal-fines-account-defendant-account-consolidated-account.interface'; +import { IOpalFinesVersion } from './opal-fines-version.interface'; + +export interface IOpalFinesAccountDefendantDetailsConsolidatedAccounts extends IOpalFinesVersion { + consolidated_accounts: IOpalFinesAccountDefendantDetailsConsolidatedAccount[]; +} diff --git a/src/app/flows/fines/services/opal-fines-service/interfaces/opal-fines-cache.interface.ts b/src/app/flows/fines/services/opal-fines-service/interfaces/opal-fines-cache.interface.ts index 770eacee13..d429f5fab0 100644 --- a/src/app/flows/fines/services/opal-fines-service/interfaces/opal-fines-cache.interface.ts +++ b/src/app/flows/fines/services/opal-fines-service/interfaces/opal-fines-cache.interface.ts @@ -1,4 +1,5 @@ import { Observable } from 'rxjs'; +import { IOpalFinesAccountDefendantDetailsConsolidatedAccounts } from './opal-fines-account-defendant-account-consolidated-accounts.interface'; import { IOpalFinesCourtRefData } from './opal-fines-court-ref-data.interface'; import { IOpalFinesBusinessUnitRefData } from './opal-fines-business-unit-ref-data.interface'; import { IOpalFinesLocalJusticeAreaRefData } from './opal-fines-local-justice-area-ref-data.interface'; @@ -38,6 +39,7 @@ export interface IOpalFinesCache { defendantAccountParentOrGuardianAccountPartyCache$: Observable | null; defendantAccountEnforcementCache$: Observable | null; defendantAccountImpositionsCache$: Observable | null; + defendantAccountConsolidatedAccountsCache$: Observable | null; defendantAccountHistoryAndNotesCache$: Observable | null; defendantAccountPaymentTermsLatestCache$: Observable | null; defendantAccountFixedPenaltyCache$: Observable | null; diff --git a/src/app/flows/fines/services/opal-fines-service/mocks/opal-fines-account-defendant-details-consolidated-accounts.mock.ts b/src/app/flows/fines/services/opal-fines-service/mocks/opal-fines-account-defendant-details-consolidated-accounts.mock.ts new file mode 100644 index 0000000000..7e4de58237 --- /dev/null +++ b/src/app/flows/fines/services/opal-fines-service/mocks/opal-fines-account-defendant-details-consolidated-accounts.mock.ts @@ -0,0 +1,17 @@ +import { IOpalFinesAccountDefendantDetailsConsolidatedAccounts } from '../interfaces/opal-fines-account-defendant-account-consolidated-accounts.interface'; + +export const OPAL_FINES_ACCOUNT_DEFENDANT_DETAILS_CONSOLIDATED_ACCOUNTS_MOCK: IOpalFinesAccountDefendantDetailsConsolidatedAccounts = + { + consolidated_accounts: [ + { + account_id: 99000000990002, + account_number: '99009902C', + date_imposed: '2026-01-21', + first_name: 'Casey', + imposed_by: 'Seed Child Court', + last_name: 'Child', + reference: 'LOCAL-CONSOL-CHILD', + }, + ], + version: '1', + }; diff --git a/src/app/flows/fines/services/opal-fines-service/opal-fines.service.spec.ts b/src/app/flows/fines/services/opal-fines-service/opal-fines.service.spec.ts index 56d9fcef75..60534ad272 100644 --- a/src/app/flows/fines/services/opal-fines-service/opal-fines.service.spec.ts +++ b/src/app/flows/fines/services/opal-fines-service/opal-fines.service.spec.ts @@ -69,6 +69,7 @@ import { FINES_ACC_MAJOR_CREDITOR_DETAILS_HEADER_MOCK } from '../../fines-acc/fi import { OPAL_FINES_ACCOUNT_MAJOR_CREDITOR_AT_A_GLANCE_MOCK } from './mocks/opal-fines-account-major-creditor-at-a-glance-with-defendant.mock'; import { OPAL_FINES_ACCOUNT_MINOR_CREDITOR_DETAILS_HISTORY_AND_NOTES_TAB_REF_DATA_MOCK } from './mocks/opal-fines-account-minor-creditor-details-history-and-notes-tab-ref-data.mock'; import { OPAL_FINES_MINOR_CREDITOR_ACCOUNT_HISTORY_PARAMS_MOCK } from './mocks/opal-fines-minor-creditor-account-history-params.mock'; +import { OPAL_FINES_ACCOUNT_DEFENDANT_DETAILS_CONSOLIDATED_ACCOUNTS_MOCK } from './mocks/opal-fines-account-defendant-details-consolidated-accounts.mock'; describe('OpalFines', () => { let service: OpalFines; @@ -1423,6 +1424,46 @@ describe('OpalFines', () => { req.flush(OPAL_FINES_ACCOUNT_DEFENDANT_DETAILS_HISTORY_AND_NOTES_TAB_REF_DATA_MOCK); }); + it('should getDefendantAccountConsolidatedAccounts', () => { + const account_id = 77; + const apiUrl = `${OPAL_FINES_PATHS.defendantAccounts}/${account_id}/consolidated-accounts`; + const expectedResponse = { + ...OPAL_FINES_ACCOUNT_DEFENDANT_DETAILS_CONSOLIDATED_ACCOUNTS_MOCK, + version: '"123"', + }; + + service.getDefendantAccountConsolidatedAccounts(account_id).subscribe((response) => { + expect(response).toEqual(expectedResponse); + }); + + const req = httpMock.expectOne(apiUrl); + expect(req.request.method).toBe('GET'); + + req.flush(OPAL_FINES_ACCOUNT_DEFENDANT_DETAILS_CONSOLIDATED_ACCOUNTS_MOCK.consolidated_accounts, { + headers: { ETag: '"123"' }, + }); + }); + + it('should return cached defendant account consolidated accounts on repeated calls', () => { + const account_id = 77; + const apiUrl = `${OPAL_FINES_PATHS.defendantAccounts}/${account_id}/consolidated-accounts`; + const expectedResponse = { + ...OPAL_FINES_ACCOUNT_DEFENDANT_DETAILS_CONSOLIDATED_ACCOUNTS_MOCK, + version: null, + }; + + service.getDefendantAccountConsolidatedAccounts(account_id).subscribe(); + + const req = httpMock.expectOne(apiUrl); + req.flush(OPAL_FINES_ACCOUNT_DEFENDANT_DETAILS_CONSOLIDATED_ACCOUNTS_MOCK.consolidated_accounts); + + service.getDefendantAccountConsolidatedAccounts(account_id).subscribe((response) => { + expect(response).toEqual(expectedResponse); + }); + + httpMock.expectNone(apiUrl); + }); + it('should send a POST request to add note API with correct payload and return mock response', () => { const payload: IOpalFinesAddNotePayload = OPAL_FINES_ADD_NOTE_PAYLOAD_MOCK; const version = '1'; @@ -1556,6 +1597,9 @@ describe('OpalFines', () => { service['cache']['defendantAccountFixedPenaltyCache$'] = of( OPAL_FINES_ACCOUNT_DEFENDANT_DETAILS_FIXED_PENALTY_MOCK, ); + service['cache']['defendantAccountConsolidatedAccountsCache$'] = of( + OPAL_FINES_ACCOUNT_DEFENDANT_DETAILS_CONSOLIDATED_ACCOUNTS_MOCK, + ); service['cache']['minorCreditorAccountAtAGlanceCache$'] = of( OPAL_FINES_ACCOUNT_MINOR_CREDITOR_AT_A_GLANCE_WITH_DEFENDANT_MOCK, ); @@ -1570,6 +1614,7 @@ describe('OpalFines', () => { expect(service['cache']['defendantAccountHistoryAndNotesCache$']).toBeNull(); expect(service['cache']['defendantAccountPaymentTermsLatestCache$']).toBeNull(); expect(service['cache']['defendantAccountFixedPenaltyCache$']).toBeNull(); + expect(service['cache']['defendantAccountConsolidatedAccountsCache$']).toBeNull(); expect(service['cache']['minorCreditorAccountAtAGlanceCache$']).toBeNull(); }); diff --git a/src/app/flows/fines/services/opal-fines-service/opal-fines.service.ts b/src/app/flows/fines/services/opal-fines-service/opal-fines.service.ts index 8ecfd46c9e..c4d2961b0f 100644 --- a/src/app/flows/fines/services/opal-fines-service/opal-fines.service.ts +++ b/src/app/flows/fines/services/opal-fines-service/opal-fines.service.ts @@ -67,6 +67,8 @@ import { IOpalFinesDraftAccountPatchRequestPayload } from '@services/fines/opal- import { IOpalFinesDeleteDefendantAccountPartyPayload } from './interfaces/opal-fines-delete-defendant-account-party-payload.interface'; import { IOpalFinesAccountMajorCreditorDetailsHeader } from '../../fines-acc/fines-acc-major-creditor-details/interfaces/fines-acc-major-creditor-details-header.interface'; import { IOpalFinesAccountMajorCreditorAtAGlance } from './interfaces/opal-fines-account-major-creditor-at-a-glance.interface'; +import { IOpalFinesAccountDefendantDetailsConsolidatedAccount } from './interfaces/opal-fines-account-defendant-account-consolidated-account.interface'; +import { IOpalFinesAccountDefendantDetailsConsolidatedAccounts } from './interfaces/opal-fines-account-defendant-account-consolidated-accounts.interface'; const SAFE_READ_RETRY_POLICY = { retryCount: 1, @@ -536,6 +538,7 @@ export class OpalFines { 'defendantAccountHistoryAndNotesCache$', 'defendantAccountPaymentTermsLatestCache$', 'defendantAccountFixedPenaltyCache$', + 'defendantAccountConsolidatedAccountsCache$', 'minorCreditorAccountAtAGlanceCache$', 'minorCreditorAccountCreditorCache$', 'minorCreditorAccountHistoryAndNotesCache$', @@ -1495,4 +1498,32 @@ export class OpalFines { } return this.cache.majorCreditorAccountAtAGlanceCache$; } + + /** + * Retrieves the defendant account consolidated accounts data. + * If the account details for the specified tab are not already cached, it makes an HTTP request to fetch the data and caches it for future use. + * + * @param account_id - The ID of the defendant account. + * @returns An Observable that emits the account details for the consolidated accounts tab. + */ + public getDefendantAccountConsolidatedAccounts( + account_id: number | null, + ): Observable { + if (!this.cache.defendantAccountConsolidatedAccountsCache$) { + const url = `${OPAL_FINES_PATHS.defendantAccounts}/${account_id}/consolidated-accounts`; + this.cache.defendantAccountConsolidatedAccountsCache$ = this.http + .get(url, { observe: 'response' }) + .pipe( + map((response: HttpResponse) => { + const version = this.extractEtagVersion(response.headers); + return { + consolidated_accounts: response.body ?? [], + version, + }; + }), + shareReplay(1), + ); + } + return this.cache.defendantAccountConsolidatedAccountsCache$; + } } diff --git a/yarn-audit-known-issues b/yarn-audit-known-issues index c8f567c29c..ffa8dfe8d1 100644 --- a/yarn-audit-known-issues +++ b/yarn-audit-known-issues @@ -4,6 +4,8 @@ {"value":"ajv","children":{"ID":1113715,"Issue":"ajv has ReDoS when using `$data` option","URL":"https://github.com/advisories/GHSA-2g4f-4pwh-qvx6","Severity":"moderate","Vulnerable Versions":">=7.0.0-alpha.0 <8.18.0","Tree Versions":["8.17.1"],"Dependents":["schema-utils@npm:4.3.3"]}} {"value":"body-parser","children":{"ID":1123976,"Issue":"body-parser vulnerable to denial of service when invalid limit value silently disables size enforcement","URL":"https://github.com/advisories/GHSA-v422-hmwv-36x6","Severity":"low","Vulnerable Versions":">=2.0.0 <2.3.0","Tree Versions":["2.2.2"],"Dependents":["opal-frontend@workspace:."]}} {"value":"fast-uri","children":{"ID":1124064,"Issue":"fast-uri vulnerable to host confusion via literal backslash authority delimiter","URL":"https://github.com/advisories/GHSA-v2hh-gcrm-f6hx","Severity":"high","Vulnerable Versions":">=3.0.0 <=3.1.3","Tree Versions":["3.1.2"],"Dependents":["ajv@npm:8.17.1"]}} +{"value":"fast-uri","children":{"ID":1124231,"Issue":"fast-uri vulnerable to host confusion via failed IDN canonicalization","URL":"https://github.com/advisories/GHSA-4c8g-83qw-93j6","Severity":"high","Vulnerable Versions":">=3.0.0 <3.1.3","Tree Versions":["3.1.2"],"Dependents":["ajv@npm:8.17.1"]}} +{"value":"fast-uri","children":{"ID":1124064,"Issue":"fast-uri vulnerable to host confusion via literal backslash authority delimiter","URL":"https://github.com/advisories/GHSA-v2hh-gcrm-f6hx","Severity":"high","Vulnerable Versions":">=3.0.0 <=3.1.3","Tree Versions":["3.1.2"],"Dependents":["ajv@npm:8.17.1"]}} {"value":"fast-uri","children":{"ID":1130129,"Issue":"fast-uri vulnerable to host confusion via failed IDN canonicalization","URL":"https://github.com/advisories/GHSA-4c8g-83qw-93j6","Severity":"high","Vulnerable Versions":">=3.0.0 <3.1.3","Tree Versions":["3.1.2"],"Dependents":["ajv@npm:8.17.1"]}} {"value":"ip-address","children":{"ID":1118827,"Issue":"ip-address has XSS in Address6 HTML-emitting methods","URL":"https://github.com/advisories/GHSA-v2v4-37r5-5v8g","Severity":"moderate","Vulnerable Versions":"<=10.1.0","Tree Versions":["9.0.5"],"Dependents":["socks@npm:2.8.3"]}} {"value":"minimatch","children":{"ID":1113465,"Issue":"minimatch has a ReDoS via repeated wildcards with non-matching literal in pattern","URL":"https://github.com/advisories/GHSA-3ppc-4f35-3m26","Severity":"high","Vulnerable Versions":">=9.0.0 <9.0.6","Tree Versions":["9.0.5"],"Dependents":["mocha@npm:11.7.6"]}}