diff --git a/cypress/e2e/functional/opal/actions/account-details/convert.account.actions.ts b/cypress/e2e/functional/opal/actions/account-details/convert.account.actions.ts new file mode 100644 index 0000000000..9dc5dc88df --- /dev/null +++ b/cypress/e2e/functional/opal/actions/account-details/convert.account.actions.ts @@ -0,0 +1,129 @@ +import { AccountConvertLocators as L } from '../../../../../shared/selectors/account-details/account.convert.locators'; +import { createScopedLogger } from '../../../../../support/utils/log.helper'; +import { CommonActions } from '../common/common.actions'; + +const log = createScopedLogger('AccountConvertActions'); + +/** Actions and assertions for the convert-account confirmation page. */ +export class AccountConvertActions { + private static readonly DEFAULT_TIMEOUT = 10_000; + private readonly common = new CommonActions(); + + /** + * Normalizes visible text for resilient assertions. + * + * @param value - Raw text content. + * @returns Lower-cased single-spaced text. + */ + private normalize(value: string): string { + return value.replace(/\s+/g, ' ').trim().toLowerCase(); + } + + /** + * Asserts the convert-to-company confirmation page content. + * + * @param expectedCaptionName - Expected defendant name in the page caption. + */ + public assertOnConvertToCompanyConfirmation(expectedCaptionName: string): void { + log('assert', 'Convert to company confirmation page is visible', { expectedCaptionName }); + cy.location('pathname', { timeout: this.common.getPathTimeout() }).should('include', '/convert/company'); + + cy.get(L.page.caption, { timeout: AccountConvertActions.DEFAULT_TIMEOUT }) + .should('be.visible') + .invoke('text') + .then((text) => { + const actual = this.normalize(text); + expect(actual).to.include(this.normalize(expectedCaptionName)); + expect(actual).to.include('-'); + }); + + cy.get(L.page.header, { timeout: AccountConvertActions.DEFAULT_TIMEOUT }) + .should('be.visible') + .should(($el) => { + const text = this.normalize($el.text()); + expect(text).to.include('are you sure you want to convert this account to a company account?'); + }); + + cy.get(L.page.warningText, { timeout: AccountConvertActions.DEFAULT_TIMEOUT }) + .should('be.visible') + .should(($el) => { + const text = this.normalize($el.text()); + expect(text).to.include( + this.normalize('Certain data related to individual accounts, such as employment details, will be removed.'), + ); + }); + + cy.get(L.page.confirmButton, { timeout: AccountConvertActions.DEFAULT_TIMEOUT }).should('exist'); + cy.get(L.page.cancelLink, { timeout: AccountConvertActions.DEFAULT_TIMEOUT }).should('exist'); + } + + /** + * Asserts the convert-to-individual confirmation page content. + * + * @param expectedCaptionName - Expected company name in the page caption. + */ + public assertOnConvertToIndividualConfirmation(expectedCaptionName: string): void { + log('assert', 'Convert to individual confirmation page is visible', { expectedCaptionName }); + cy.location('pathname', { timeout: this.common.getPathTimeout() }).should('include', '/convert/individual'); + + cy.get(L.page.caption, { timeout: AccountConvertActions.DEFAULT_TIMEOUT }) + .should('be.visible') + .invoke('text') + .then((text) => { + const actual = this.normalize(text); + expect(actual).to.include(this.normalize(expectedCaptionName)); + expect(actual).to.include('-'); + }); + + cy.get(L.page.header, { timeout: AccountConvertActions.DEFAULT_TIMEOUT }) + .should('be.visible') + .should(($el) => { + const text = this.normalize($el.text()); + expect(text).to.include('are you sure you want to convert this account to an individual account?'); + }); + + cy.get(L.page.warningText, { timeout: AccountConvertActions.DEFAULT_TIMEOUT }) + .should('be.visible') + .should(($el) => { + const text = this.normalize($el.text()); + expect(text).to.include( + this.normalize('Some information specific to company accounts, such as company name, will be removed.'), + ); + }); + + cy.get(L.page.confirmButton, { timeout: AccountConvertActions.DEFAULT_TIMEOUT }).should('exist'); + cy.get(L.page.cancelLink, { timeout: AccountConvertActions.DEFAULT_TIMEOUT }).should('exist'); + } + + /** + * Clicks the confirmation button to continue to Company details. + */ + public confirmConvertToCompany(): void { + log('action', 'Confirming account conversion to company'); + cy.get(L.page.confirmButton, { timeout: AccountConvertActions.DEFAULT_TIMEOUT }).should('be.visible').click(); + } + + /** + * Clicks the cancel link to return to Defendant details. + */ + public cancelConvertToCompany(): void { + log('action', 'Cancelling account conversion to company'); + cy.get(L.page.cancelLink, { timeout: AccountConvertActions.DEFAULT_TIMEOUT }).should('be.visible').click(); + } + + /** + * Clicks the confirmation button to continue to Defendant details. + */ + public confirmConvertToIndividual(): void { + log('action', 'Confirming account conversion to individual'); + cy.get(L.page.confirmButton, { timeout: AccountConvertActions.DEFAULT_TIMEOUT }).should('be.visible').click(); + } + + /** + * Clicks the cancel link to return to Defendant details. + */ + public cancelConvertToIndividual(): void { + log('action', 'Cancelling account conversion to individual'); + cy.get(L.page.cancelLink, { timeout: AccountConvertActions.DEFAULT_TIMEOUT }).should('be.visible').click(); + } +} diff --git a/cypress/e2e/functional/opal/actions/account-details/details.defendant.actions.ts b/cypress/e2e/functional/opal/actions/account-details/details.defendant.actions.ts index 54b57e166f..f4044ac9e9 100644 --- a/cypress/e2e/functional/opal/actions/account-details/details.defendant.actions.ts +++ b/cypress/e2e/functional/opal/actions/account-details/details.defendant.actions.ts @@ -56,4 +56,75 @@ export class AccountDetailsDefendantActions { assertDefendantNameContains(expected: string): void { cy.get(L.defendant.fields.name, this.common.getTimeoutOptions()).should('contain.text', expected); } + + /** + * Asserts the defendant summary card is rendered in the Defendant tab. + */ + assertDefendantSummaryVisible(): void { + cy.get(L.defendant.card, this.common.getTimeoutOptions()).should('be.visible'); + } + + /** + * Asserts the defendant summary card is not rendered in the Defendant tab. + */ + assertDefendantSummaryNotPresent(): void { + cy.get(L.defendant.card, this.common.getTimeoutOptions()).should('not.exist'); + } + + /** + * Asserts the primary email address shown in the contact summary contains the expected value. + * + * @param expected - Expected text within the primary email field. + */ + assertPrimaryEmailContains(expected: string): void { + cy.get(L.contact.fields.primaryEmail, this.common.getTimeoutOptions()).should('contain.text', expected); + } + + /** + * Asserts that the convert-to-company action is visible in the Defendant tab. + */ + assertConvertToCompanyActionVisible(): void { + cy.get(L.actions.convertAction, this.common.getTimeoutOptions()) + .should('be.visible') + .and('contain.text', 'Convert to a company account'); + } + + /** + * Asserts that the convert-to-individual action is visible in the Defendant tab. + */ + assertConvertToIndividualActionVisible(): void { + cy.get(L.actions.convertAction, this.common.getTimeoutOptions()) + .should('be.visible') + .and('contain.text', 'Convert to an individual account'); + } + + /** + * Asserts that the visible convert action does not contain the company label. + */ + assertConvertToCompanyActionTextNotPresent(): void { + cy.get(L.actions.convertAction, this.common.getTimeoutOptions()) + .should('be.visible') + .and('not.contain.text', 'Convert to a company account'); + } + + /** + * Clicks the convert-to-company action from the Defendant tab. + */ + startConvertToCompanyAccount(): void { + cy.get(L.actions.convertActionLink, this.common.getTimeoutOptions()).should('be.visible').click(); + } + + /** + * Clicks the convert-to-individual action from the Defendant tab. + */ + startConvertToIndividualAccount(): void { + cy.get(L.actions.convertActionLink, this.common.getTimeoutOptions()).should('be.visible').click(); + } + + /** + * Asserts that the convert-to-company action is not rendered in the Defendant tab. + */ + assertConvertToCompanyActionNotPresent(): void { + cy.get(L.actions.convertAction, this.common.getTimeoutOptions()).should('not.exist'); + } } diff --git a/cypress/e2e/functional/opal/actions/account-details/details.nav.actions.ts b/cypress/e2e/functional/opal/actions/account-details/details.nav.actions.ts index 0e68ccd31c..5a364fd0d6 100644 --- a/cypress/e2e/functional/opal/actions/account-details/details.nav.actions.ts +++ b/cypress/e2e/functional/opal/actions/account-details/details.nav.actions.ts @@ -181,4 +181,18 @@ export class AccountDetailsNavActions { .and('have.attr', 'aria-current', 'page') .and('contain.text', 'Payment terms'); } + + /** + * Asserts the account details success banner shows the expected message. + * + * @param expected - Expected success banner text. + */ + assertSuccessBannerText(expected: string): void { + log('assert', 'Asserting account details success banner text', { expected }); + + cy.get(N.banners.success, { timeout: 10_000 }) + .should('be.visible') + .find(N.banners.successText) + .should('contain.text', expected); + } } diff --git a/cypress/e2e/functional/opal/actions/account-details/edit.company-details.actions.ts b/cypress/e2e/functional/opal/actions/account-details/edit.company-details.actions.ts index ea79865e68..110aac1b1a 100644 --- a/cypress/e2e/functional/opal/actions/account-details/edit.company-details.actions.ts +++ b/cypress/e2e/functional/opal/actions/account-details/edit.company-details.actions.ts @@ -12,7 +12,21 @@ const log = createScopedLogger('EditCompanyDetailsActions'); /** Actions for editing company details within Account Details. */ export class EditCompanyDetailsActions { + private static readonly DEFAULT_TIMEOUT = 10_000; private readonly common = new CommonActions(); + private readonly companyFieldLocators = { + 'Address line 1': L.fields.addressLine1, + 'Address line 2': L.fields.addressLine2, + 'Address line 3': L.fields.addressLine3, + Postcode: L.fields.postcode, + 'Primary email address': L.fields.primaryEmail, + 'Secondary email address': L.fields.secondaryEmail, + 'Mobile telephone number': L.fields.mobileTelephone, + 'Home telephone number': L.fields.homeTelephone, + 'Work telephone number': L.fields.workTelephone, + 'Make and model': L.fields.vehicleMakeModel, + 'Registration number': L.fields.vehicleRegistration, + } as const; /** * Ensure we are still on the edit page (form visible, not navigated away). @@ -23,6 +37,18 @@ export class EditCompanyDetailsActions { log('done', 'Company edit form is visible'); } + /** + * Asserts the convert handoff lands on the Company details convert route. + */ + public assertOnConvertRoute(): void { + log('assert', 'Asserting Company details convert route'); + cy.location('pathname', { timeout: this.common.getPathTimeout() }).should( + 'match', + /\/fines\/account\/defendant\/\d+\/party\/company\/convert$/, + ); + cy.get(L.form, { timeout: EditCompanyDetailsActions.DEFAULT_TIMEOUT }).should('be.visible'); + } + /** * Update the company name field on the edit form. * @@ -102,4 +128,37 @@ export class EditCompanyDetailsActions { cy.get(SummaryL.fields.name, this.common.getTimeoutOptions()).should('be.visible').and('contain.text', expected); log('done', `Verified company name contains "${expected}"`); } + + /** + * Asserts the company summary card is rendered in the Defendant tab. + */ + public assertCompanySummaryVisible(): void { + log('assert', 'Asserting company summary card is visible'); + cy.get(SummaryL.card, this.common.getTimeoutOptions()).should('be.visible'); + } + + /** + * Asserts the company summary card is not rendered in the Defendant tab. + */ + public assertCompanySummaryNotPresent(): void { + log('assert', 'Asserting company summary card is absent'); + cy.get(SummaryL.card, this.common.getTimeoutOptions()).should('not.exist'); + } + + /** + * Asserts Company details form fields are pre-populated with the expected values. + * + * @param expectedFieldValues - Key/value map of ticket field labels to expected values. + */ + public assertPrefilledFieldValues(expectedFieldValues: Record): void { + Object.entries(expectedFieldValues).forEach(([fieldName, expectedValue]) => { + const fieldSelector = this.companyFieldLocators[fieldName as keyof typeof this.companyFieldLocators]; + if (!fieldSelector) { + throw new Error(`Unsupported company prefill field: ${fieldName}`); + } + + log('assert', 'Asserting company field prefill', { fieldName, expectedValue }); + cy.get(fieldSelector, this.common.getTimeoutOptions()).should('have.value', expectedValue); + }); + } } diff --git a/cypress/e2e/functional/opal/actions/account-details/edit.defendant-details.actions.ts b/cypress/e2e/functional/opal/actions/account-details/edit.defendant-details.actions.ts index e2089d5f4f..90b6048674 100644 --- a/cypress/e2e/functional/opal/actions/account-details/edit.defendant-details.actions.ts +++ b/cypress/e2e/functional/opal/actions/account-details/edit.defendant-details.actions.ts @@ -5,11 +5,28 @@ import { DefendantDetailsLocators as L } from '../../../../../shared/selectors/account-details/edit.defendant.details.locators'; import { createScopedLogger } from '../../../../../support/utils/log.helper'; +import { CommonActions } from '../common/common.actions'; const log = createScopedLogger('EditDefendantDetailsActions'); /** Actions for editing defendant details within Account Details. */ export class EditDefendantDetailsActions { + private static readonly DEFAULT_TIMEOUT = 10_000; + private readonly common = new CommonActions(); + private readonly defendantFieldLocators = { + 'Address line 1': L.addressLine1Input, + 'Address line 2': L.addressLine2Input, + 'Address line 3': L.addressLine3Input, + Postcode: L.postcodeInput, + 'Primary email address': L.primaryEmailInput, + 'Secondary email address': L.secondaryEmailInput, + 'Mobile telephone number': L.mobileTelInput, + 'Home telephone number': L.homeTelInput, + 'Work telephone number': L.workTelInput, + 'Make and model': L.vehicleMakeModelInput, + 'Registration number': L.vehicleRegInput, + } as const; + /** * Ensures the user is still on the edit page (form visible, not navigated away). */ @@ -19,6 +36,51 @@ export class EditDefendantDetailsActions { log('done', 'Defendant Details edit form confirmed visible'); } + /** + * Asserts the convert handoff lands on the Defendant details convert route. + */ + public assertOnConvertRoute(): void { + log('assert', 'Asserting Defendant details convert route'); + cy.location('pathname', { timeout: this.common.getPathTimeout() }).should( + 'match', + /\/fines\/account\/defendant\/\d+\/party\/individual\/convert$/, + ); + cy.get(L.form.selector, { timeout: EditDefendantDetailsActions.DEFAULT_TIMEOUT }).should('be.visible'); + } + + /** + * Asserts Defendant details form fields are pre-populated with the expected values. + * + * @param expectedFieldValues - Key/value map of ticket field labels to expected values. + */ + public assertPrefilledFieldValues(expectedFieldValues: Record): void { + Object.entries(expectedFieldValues).forEach(([fieldName, expectedValue]) => { + const fieldSelector = this.defendantFieldLocators[fieldName as keyof typeof this.defendantFieldLocators]; + if (!fieldSelector) { + throw new Error(`Unsupported defendant prefill field: ${fieldName}`); + } + + log('assert', 'Asserting defendant field prefill', { fieldName, expectedValue }); + cy.get(fieldSelector.selector, this.common.getTimeoutOptions()).should('have.value', expectedValue); + }); + } + + /** + * Selects the title on the defendant details form. + * + * @param value - Title option text to select. + * @param opts Optional configuration. + * @param opts.timeout Max time to wait for the form/field visibility. + */ + public selectTitle(value: string, opts?: { timeout?: number }): void { + const timeout = opts?.timeout ?? 10_000; + + log('method', `Selecting Title value: "${value}"`); + cy.get(L.form.selector, { timeout }).should('be.visible'); + cy.get(L.titleSelect.selector, { timeout }).should('be.visible').select(value); + cy.get(L.titleSelect.selector, { timeout }).find('option:selected').should('contain.text', value); + } + /** * Updates the "First names" field on the edit form. * @@ -53,6 +115,37 @@ export class EditDefendantDetailsActions { } } + /** + * Updates the "Last name" field on the edit form. + * + * @param value - The new last name to enter. + * @param opts Optional configuration. + * @param opts.timeout Max time to wait for elements (default 10_000ms). + * @param opts.assert Whether to assert the value after typing (default true). + */ + public updateSurname(value: string, opts?: { timeout?: number; assert?: boolean }): void { + const timeout = opts?.timeout ?? 10_000; + + log('method', `Updating Last Name field to: "${value}"`); + + cy.get(L.form.selector, { timeout }).should('be.visible'); + + log('action', 'Typing into Last Name field'); + cy.get(L.surnameInput.selector, { timeout }) + .should('be.visible') + .and('be.enabled') + .scrollIntoView() + .clear({ force: true }) + .type(value) + .blur(); + + if (opts?.assert !== false) { + log('assert', `Verifying Last Name field value equals "${value}"`); + cy.get(L.surnameInput.selector).should('have.value', value.toUpperCase()); + log('done', 'Last Name field value updated successfully'); + } + } + /** * Asserts that the "First names" input value matches the expected text. * diff --git a/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetails.feature b/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetails.feature index 8f93ad7d88..42cc3660e1 100644 --- a/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetails.feature +++ b/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetails.feature @@ -27,6 +27,7 @@ Feature: Account Enquiries – View Account Details Then I should see the page header contains "Mr John ACCDETAILSURNAME{uniqUpper}" # AC3 – Navigate to Defendant details When I go to the Defendant details section and the header is "Defendant details" + Then I should see the convert to company account action @PO-1593 @866 @PO-1110 @PO-1127 Scenario: Defendant edit warning retains changes when I stay on the form @@ -65,6 +66,32 @@ Feature: Account Enquiries – View Account Details And I should see the account header contains "Mr Updated ACCDETAILSURNAME{uniqUpper}" And I verify no amendments were created via API + @PO-1942 @PO-1943 + Scenario: Convert to company saves and shows the converted company account details + When I start converting the account to a company account + Then I should see the convert to company confirmation screen for defendant "Mr John ACCDETAILSURNAME{uniqUpper}" + When I continue converting the account to a company account + Then I should be on the Company details convert route + Then the Company details form should be pre-populated with: + | Primary email address | John.AccDetailSurname{uniq}@test.com | + | Home telephone number | 02078259314 | + When I complete converting the account to a company with company name "Accdetail converted comp{uniq}" + Then I should return to the account details page Defendant tab + And I should see the account conversion success message "Converted to a company account." + When I go to the Defendant details section and the header is "Company details" + Then I should see the company summary card + And I should not see the defendant summary card + And I should see the company name contains "Accdetail converted comp{uniq}" + And I should see the primary email address contains "John.AccDetailSurname{uniq}@test.com" + + @PO-1943 + Scenario: Convert to company confirmation cancel returns to Defendant details with no changes made + When I start converting the account to a company account + Then I should see the convert to company confirmation screen for defendant "Mr John ACCDETAILSURNAME{uniqUpper}" + When I cancel converting the account to a company account + Then I should return to the account details page Defendant tab + And I should see the convert to company account action + Rule: Company account baseline Background: # AC1 – Account setup @@ -79,6 +106,8 @@ Feature: Account Enquiries – View Account Details Then I should see the account header contains "Accdetail comp{uniq}" # AC3 – Navigate to Company details When I go to the Defendant details section and the header is "Company details" + Then I should see the convert to individual account action + And I should not see the convert to company account text @967 @PO-1111 @PO-1128 Scenario: Company edit warning retains changes when I stay on the form @@ -117,6 +146,33 @@ Feature: Account Enquiries – View Account Details And I should see the account header contains "Accdetail comp updated{uniq}" And I verify no amendments were created via API for company details + @PO-1956 + Scenario: Convert to individual saves and shows the converted defendant account details + When I start converting the account to an individual account + Then I should see the convert to individual confirmation screen for company "Accdetail comp{uniq}" + When I continue converting the account to an individual account + Then I should be on the Defendant details convert route + And the Defendant details form should be pre-populated with: + | Postcode | AB23 4RN | + | Primary email address | Accdetailcomp{uniq}@test.com | + When I complete converting the account to an individual with title "Miss", first name "Jamie", and last name "Converted{uniq}" + Then I should return to the account details page Defendant tab + And I should see the account conversion success message "Converted to an individual account." + When I go to the Defendant details section and the header is "Defendant details" + Then I should see the defendant summary card + And I should not see the company summary card + And I should see the defendant name contains "Jamie" + And I should see the primary email address contains "Accdetailcomp{uniq}@test.com" + + @PO-1956 + Scenario: Convert to individual confirmation cancel returns to Defendant details with no changes made + When I start converting the account to an individual account + Then I should see the convert to individual confirmation screen for company "Accdetail comp{uniq}" + When I cancel converting the account to an individual account + Then I should return to the account details page Defendant tab + And I should see the convert to individual account action + And I should not see the convert to company account text + Rule: Non-paying defendant account baseline Background: # AC1 – Account setup @@ -131,6 +187,7 @@ Feature: Account Enquiries – View Account Details Then I should see the page header contains "Miss Jane TESTNONPAYEE{uniqUpper}" # AC3 – Navigate to Defendant details When I go to the Defendant details section and the header is "Defendant details" + Then I should not see the convert to company account action @PO-2315 @PO-1663 Scenario: Defendant edit warning retains changes for a non-paying account when I stay diff --git a/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetailsAccessibility.feature b/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetailsAccessibility.feature index b8f0ab876e..1417d091ed 100644 --- a/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetailsAccessibility.feature +++ b/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetailsAccessibility.feature @@ -21,8 +21,13 @@ Feature: Account Enquiries - View Account Details Accessibility ## Check Accessibility on Search Results Page Then I check the page for accessibility And I select the latest published account and verify the header is "Mr John ACCDETAILSURNAME{uniqUpper}" - ## Check Accessibility on Account Details Page + And I go to the Defendant details section and the header is "Defendant details" + And I should see the convert to company account action + ## Check Accessibility on Defendant Details Page Then I check the page for accessibility + When I start converting the account to a company account + Then I should see the convert to company confirmation screen for defendant "Mr John ACCDETAILSURNAME{uniqUpper}" + And I check the page for accessibility Scenario: Check Account Details View Accessibility with Axe-Core for Company Account Given I create a "company" draft account with the following details and set status "Publishing Pending" using user "opal-test-10@hmcts.net": @@ -38,6 +43,12 @@ Feature: Account Enquiries - View Account Details Accessibility When I search for the account by company name "Accdetail comp{uniq}" # Check Accessibility on Company Search Results Page Then I check the page for accessibility - # Check Accessibility on Company Account Details Page + # Check Accessibility on Company Defendant Details Page And I select the latest published account and verify the header is "Accdetail comp{uniqUpper}" + And I go to the Defendant details section and the header is "Company details" + And I should see the convert to individual account action + And I should not see the convert to company account text Then I check the page for accessibility + When I start converting the account to an individual account + Then I should see the convert to individual confirmation screen for company "Accdetail comp{uniq}" + And 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 947d4d2dcc..7844ddc980 100644 --- a/cypress/e2e/functional/opal/flows/account-enquiry.flow.ts +++ b/cypress/e2e/functional/opal/flows/account-enquiry.flow.ts @@ -17,6 +17,7 @@ import { CommonActions } from '../actions/common/common.actions'; import { EditDefendantDetailsActions } from '../actions/account-details/edit.defendant-details.actions'; import { EditCompanyDetailsActions } from '../actions/account-details/edit.company-details.actions'; import { EditParentGuardianDetailsActions } from '../actions/account-details/edit.parent-guardian-details.actions'; +import { AccountConvertActions } from '../actions/account-details/convert.account.actions'; import { createScopedLogger, createScopedSyncLogger } from '../../../../support/utils/log.helper'; const logAE = createScopedLogger('AccountEnquiryFlow'); @@ -71,6 +72,7 @@ export class AccountEnquiryFlow { private readonly editCompanyDetailsActions = new EditCompanyDetailsActions(); private readonly editParentGuardianActions = new EditParentGuardianDetailsActions(); private readonly paymentTerms = new AccountDetailsPaymentTermsActions(); + private readonly accountConvert = new AccountConvertActions(); /** * Ensures the test is on the Individuals Account Search page. @@ -213,6 +215,223 @@ export class AccountEnquiryFlow { this.defendantDetails.assertSectionHeader(headerText); } + /** + * Asserts the Defendant tab shows the convert-to-company action. + */ + public assertConvertToCompanyActionVisible(): void { + logAE('method', 'assertConvertToCompanyActionVisible()'); + this.defendantDetails.assertConvertToCompanyActionVisible(); + } + + /** + * Asserts the Defendant tab shows the convert-to-individual action. + */ + public assertConvertToIndividualActionVisible(): void { + logAE('method', 'assertConvertToIndividualActionVisible()'); + this.defendantDetails.assertConvertToIndividualActionVisible(); + } + + /** + * Asserts the Defendant tab does not show the convert-to-company action. + */ + public assertConvertToCompanyActionNotPresent(): void { + logAE('method', 'assertConvertToCompanyActionNotPresent()'); + this.defendantDetails.assertConvertToCompanyActionNotPresent(); + } + + /** + * Asserts the visible convert action is not the company-convert label. + */ + public assertConvertToCompanyActionTextNotPresent(): void { + logAE('method', 'assertConvertToCompanyActionTextNotPresent()'); + this.defendantDetails.assertConvertToCompanyActionTextNotPresent(); + } + + /** + * Opens the convert-to-company confirmation page from the Defendant tab. + */ + public openConvertToCompanyConfirmation(): void { + logAE('method', 'openConvertToCompanyConfirmation()'); + this.detailsNav.goToDefendantTab(); + this.defendantDetails.startConvertToCompanyAccount(); + } + + /** + * Opens the convert-to-individual confirmation page from the Defendant tab. + */ + public openConvertToIndividualConfirmation(): void { + logAE('method', 'openConvertToIndividualConfirmation()'); + this.detailsNav.goToDefendantTab(); + this.defendantDetails.startConvertToIndividualAccount(); + } + + /** + * Asserts the convert-to-company confirmation page. + * + * @param expectedCaptionName - Expected defendant name shown in the caption. + */ + public assertOnConvertToCompanyConfirmation(expectedCaptionName: string): void { + logAE('method', 'assertOnConvertToCompanyConfirmation()', { expectedCaptionName }); + this.accountConvert.assertOnConvertToCompanyConfirmation(expectedCaptionName); + } + + /** + * Confirms the convert-to-company action. + */ + public confirmConvertToCompanyAccount(): void { + logAE('method', 'confirmConvertToCompanyAccount()'); + this.accountConvert.confirmConvertToCompany(); + } + + /** + * Cancels the convert-to-company action. + */ + public cancelConvertToCompanyAccount(): void { + logAE('method', 'cancelConvertToCompanyAccount()'); + this.accountConvert.cancelConvertToCompany(); + } + + /** + * Asserts the convert-to-individual confirmation page. + * + * @param expectedCaptionName - Expected company name shown in the caption. + */ + public assertOnConvertToIndividualConfirmation(expectedCaptionName: string): void { + logAE('method', 'assertOnConvertToIndividualConfirmation()', { expectedCaptionName }); + this.accountConvert.assertOnConvertToIndividualConfirmation(expectedCaptionName); + } + + /** + * Confirms the convert-to-individual action. + */ + public confirmConvertToIndividualAccount(): void { + logAE('method', 'confirmConvertToIndividualAccount()'); + this.accountConvert.confirmConvertToIndividual(); + } + + /** + * Cancels the convert-to-individual action. + */ + public cancelConvertToIndividualAccount(): void { + logAE('method', 'cancelConvertToIndividualAccount()'); + this.accountConvert.cancelConvertToIndividual(); + } + + /** + * Asserts the Company details form contains the expected pre-populated values. + * + * @param expectedFieldValues - Key/value map of ticket field labels to expected values. + */ + public assertCompanyDetailsPrefilledValues(expectedFieldValues: Record): void { + logAE('method', 'assertCompanyDetailsPrefilledValues()', expectedFieldValues); + this.editCompanyDetailsActions.assertPrefilledFieldValues(expectedFieldValues); + } + + /** + * Asserts the convert flow lands on the Company details convert route. + */ + public assertOnCompanyDetailsConvertRoute(): void { + logAE('method', 'assertOnCompanyDetailsConvertRoute()'); + this.editCompanyDetailsActions.assertOnConvertRoute(); + } + + /** + * Asserts the convert flow lands on the Defendant details convert route. + */ + public assertOnDefendantDetailsConvertRoute(): void { + logAE('method', 'assertOnDefendantDetailsConvertRoute()'); + this.editDefendantDetailsActions.assertOnConvertRoute(); + } + + /** + * Asserts the Defendant details form contains the expected pre-populated values. + * + * @param expectedFieldValues - Key/value map of ticket field labels to expected values. + */ + public assertDefendantDetailsPrefilledValues(expectedFieldValues: Record): void { + logAE('method', 'assertDefendantDetailsPrefilledValues()', expectedFieldValues); + this.editDefendantDetailsActions.assertPrefilledFieldValues(expectedFieldValues); + } + + /** + * Completes the convert-to-company form by providing a company name and saving the form. + * + * @param companyName - Company name to use for the converted account. + */ + public completeConvertToCompany(companyName: string): void { + logAE('method', 'completeConvertToCompany()', { companyName }); + this.editCompanyDetailsActions.editCompanyName(companyName); + this.editCompanyDetailsActions.saveChanges(); + } + + /** + * Completes the convert-to-individual form by filling the required personal details and saving. + * + * @param details - Title and name fields required for the converted individual account. + * @param details.title - Title to select for the converted individual account. + * @param details.firstName - First name to enter for the converted individual account. + * @param details.lastName - Last name to enter for the converted individual account. + */ + public completeConvertToIndividual(details: { title: string; firstName: string; lastName: string }): void { + logAE('method', 'completeConvertToIndividual()', details); + this.editDefendantDetailsActions.selectTitle(details.title); + this.editDefendantDetailsActions.updateFirstName(details.firstName); + this.editDefendantDetailsActions.updateSurname(details.lastName); + this.editDefendantDetailsActions.saveChanges(); + } + + /** + * Asserts the account details success banner contains the expected conversion message. + * + * @param expected - Expected banner text. + */ + public assertAccountConversionSuccessMessage(expected: string): void { + logAE('method', 'assertAccountConversionSuccessMessage()', { expected }); + this.detailsNav.assertSuccessBannerText(expected); + } + + /** + * Asserts the company summary card is visible in the Defendant tab. + */ + public assertCompanySummaryVisible(): void { + logAE('method', 'assertCompanySummaryVisible()'); + this.editCompanyDetailsActions.assertCompanySummaryVisible(); + } + + /** + * Asserts the company summary card is not visible in the Defendant tab. + */ + public assertCompanySummaryNotPresent(): void { + logAE('method', 'assertCompanySummaryNotPresent()'); + this.editCompanyDetailsActions.assertCompanySummaryNotPresent(); + } + + /** + * Asserts the defendant summary card is visible in the Defendant tab. + */ + public assertDefendantSummaryVisible(): void { + logAE('method', 'assertDefendantSummaryVisible()'); + this.defendantDetails.assertDefendantSummaryVisible(); + } + + /** + * Asserts the defendant summary card is not visible in the Defendant tab. + */ + public assertDefendantSummaryNotPresent(): void { + logAE('method', 'assertDefendantSummaryNotPresent()'); + this.defendantDetails.assertDefendantSummaryNotPresent(); + } + + /** + * Asserts the primary email address shown in the contact card contains the expected value. + * + * @param expected - Expected email text. + */ + public assertPrimaryEmailContains(expected: string): void { + logAE('method', 'assertPrimaryEmailContains()', { expected }); + this.defendantDetails.assertPrimaryEmailContains(expected); + } + /** * Navigates to the Parent/Guardian tab and asserts a specific section header. * diff --git a/cypress/shared/selectors/account-details/account.convert.locators.ts b/cypress/shared/selectors/account-details/account.convert.locators.ts new file mode 100644 index 0000000000..c8347ca447 --- /dev/null +++ b/cypress/shared/selectors/account-details/account.convert.locators.ts @@ -0,0 +1,14 @@ +/** + * @file account.convert.locators.ts + * @description Selector map for the Defendant account convert confirmation page. + */ +export const AccountConvertLocators = { + page: { + root: 'main[role="main"]', + heading: '#account-convert-heading', + caption: '#account-convert-heading .govuk-caption-l', + warningText: '#account-convert-warning', + confirmButton: '#account-convert-confirm', + cancelLink: '#account-convert-cancel a.govuk-link', + }, +} as const; diff --git a/cypress/shared/selectors/account-details/account.defendant.details.locators.ts b/cypress/shared/selectors/account-details/account.defendant.details.locators.ts index f863971b56..e1ae798c32 100644 --- a/cypress/shared/selectors/account-details/account.defendant.details.locators.ts +++ b/cypress/shared/selectors/account-details/account.defendant.details.locators.ts @@ -167,9 +167,12 @@ export const AccountDefendantDetailsLocators = { // ────────────────────────────── actions: { /** Container for the right column actions within Defendant tab. */ - sideColumn: 'app-fines-acc-defendant-details-defendant-tab .govuk-grid-column-one-third', + sideColumn: '#defendant-convert-actions', - /** “Convert to a company account” action link. */ - convertToCompany: 'app-fines-acc-defendant-details-defendant-tab .govuk-grid-column-one-third p > a', + /** Convert action text within the Defendant tab actions column. */ + convertAction: '#defendant-convert-action', + + /** Interactive convert action link, when present. */ + convertActionLink: '#defendant-convert-action-link', }, }; diff --git a/cypress/shared/selectors/account-details/account.nav.details.locators.ts b/cypress/shared/selectors/account-details/account.nav.details.locators.ts index e4f30d5cb6..aac658d196 100644 --- a/cypress/shared/selectors/account-details/account.nav.details.locators.ts +++ b/cypress/shared/selectors/account-details/account.nav.details.locators.ts @@ -125,6 +125,12 @@ export const AccountNavDetailsLocators = { addCommentsLink: 'a.govuk-link[href*="comments"], a.govuk-link:contains("Add comments")', }, + /** Page-level banner messages rendered above the account details header. */ + banners: { + success: 'opal-lib-moj-alert[type="success"]', + successText: 'opal-lib-moj-alert-content-text', + }, + // ────────────────────────────── // Shell-level widgets // ────────────────────────────── diff --git a/cypress/support/step_definitions/searchForAccount/account-enquiry.steps.ts b/cypress/support/step_definitions/searchForAccount/account-enquiry.steps.ts index 026390add6..4e92bd62b0 100644 --- a/cypress/support/step_definitions/searchForAccount/account-enquiry.steps.ts +++ b/cypress/support/step_definitions/searchForAccount/account-enquiry.steps.ts @@ -114,6 +114,159 @@ When('I go to the Defendant details section and the header is {string}', (expect flow().goToDefendantDetailsAndAssert(expectedWithUniq); }); +Then('I should see the convert to company account action', () => { + log('assert', 'Convert to company account action is visible'); + flow().assertConvertToCompanyActionVisible(); +}); + +Then('I should see the convert to individual account action', () => { + log('assert', 'Convert to individual account action is visible'); + flow().assertConvertToIndividualActionVisible(); +}); + +Then('I should not see the convert to company account action', () => { + log('assert', 'Convert to company account action is absent'); + flow().assertConvertToCompanyActionNotPresent(); +}); + +Then('I should not see the convert to company account text', () => { + log('assert', 'Convert to company account text is absent from the visible action'); + flow().assertConvertToCompanyActionTextNotPresent(); +}); + +When('I start converting the account to a company account', () => { + log('step', 'Start converting account to company'); + flow().openConvertToCompanyConfirmation(); +}); + +Then( + 'I should see the convert to company confirmation screen for defendant {string}', + (expectedCaptionName: string) => { + const expectedCaptionNameWithUniq = applyUniqPlaceholder(expectedCaptionName); + log('assert', 'Convert to company confirmation screen is visible', { + expectedCaptionName: expectedCaptionNameWithUniq, + }); + flow().assertOnConvertToCompanyConfirmation(expectedCaptionNameWithUniq); + }, +); + +When('I continue converting the account to a company account', () => { + log('step', 'Continue converting account to company'); + flow().confirmConvertToCompanyAccount(); +}); + +Then('I should be on the Company details convert route', () => { + log('assert', 'Company details convert route is active'); + flow().assertOnCompanyDetailsConvertRoute(); +}); + +When('I cancel converting the account to a company account', () => { + log('step', 'Cancel converting account to company'); + flow().cancelConvertToCompanyAccount(); +}); + +Then('the Company details form should be pre-populated with:', (table: DataTable) => { + const expectedFieldValues = Object.fromEntries( + Object.entries(rowsHashSafe(table)).map(([fieldName, fieldValue]) => [fieldName, applyUniqPlaceholder(fieldValue)]), + ); + log('assert', 'Company details form is pre-populated', expectedFieldValues); + flow().assertCompanyDetailsPrefilledValues(expectedFieldValues); +}); + +When('I start converting the account to an individual account', () => { + log('step', 'Start converting account to individual'); + flow().openConvertToIndividualConfirmation(); +}); + +Then( + 'I should see the convert to individual confirmation screen for company {string}', + (expectedCaptionName: string) => { + const expectedCaptionNameWithUniq = applyUniqPlaceholder(expectedCaptionName); + log('assert', 'Convert to individual confirmation screen is visible', { + expectedCaptionName: expectedCaptionNameWithUniq, + }); + flow().assertOnConvertToIndividualConfirmation(expectedCaptionNameWithUniq); + }, +); + +When('I continue converting the account to an individual account', () => { + log('step', 'Continue converting account to individual'); + flow().confirmConvertToIndividualAccount(); +}); + +Then('I should be on the Defendant details convert route', () => { + log('assert', 'Defendant details convert route is active'); + flow().assertOnDefendantDetailsConvertRoute(); +}); + +When('I cancel converting the account to an individual account', () => { + log('step', 'Cancel converting account to individual'); + flow().cancelConvertToIndividualAccount(); +}); + +Then('the Defendant details form should be pre-populated with:', (table: DataTable) => { + const expectedFieldValues = Object.fromEntries( + Object.entries(rowsHashSafe(table)).map(([fieldName, fieldValue]) => [fieldName, applyUniqPlaceholder(fieldValue)]), + ); + log('assert', 'Defendant details form is pre-populated', expectedFieldValues); + flow().assertDefendantDetailsPrefilledValues(expectedFieldValues); +}); + +When('I complete converting the account to a company with company name {string}', (companyName: string) => { + const companyNameWithUniq = applyUniqPlaceholder(companyName); + log('step', 'Complete converting account to company', { companyName: companyNameWithUniq }); + flow().completeConvertToCompany(companyNameWithUniq); +}); + +When( + 'I complete converting the account to an individual with title {string}, first name {string}, and last name {string}', + (title: string, firstName: string, lastName: string) => { + const firstNameWithUniq = applyUniqPlaceholder(firstName); + const lastNameWithUniq = applyUniqPlaceholder(lastName); + log('step', 'Complete converting account to individual', { + title, + firstName: firstNameWithUniq, + lastName: lastNameWithUniq, + }); + flow().completeConvertToIndividual({ + title, + firstName: firstNameWithUniq, + lastName: lastNameWithUniq, + }); + }, +); + +Then('I should see the account conversion success message {string}', (expected: string) => { + log('assert', 'Account conversion success message is visible', { expected }); + flow().assertAccountConversionSuccessMessage(expected); +}); + +Then('I should see the company summary card', () => { + log('assert', 'Company summary card is visible'); + flow().assertCompanySummaryVisible(); +}); + +Then('I should not see the company summary card', () => { + log('assert', 'Company summary card is absent'); + flow().assertCompanySummaryNotPresent(); +}); + +Then('I should see the defendant summary card', () => { + log('assert', 'Defendant summary card is visible'); + flow().assertDefendantSummaryVisible(); +}); + +Then('I should not see the defendant summary card', () => { + log('assert', 'Defendant summary card is absent'); + flow().assertDefendantSummaryNotPresent(); +}); + +Then('I should see the primary email address contains {string}', (expected: string) => { + const expectedWithUniq = applyUniqPlaceholder(expected); + log('assert', 'Primary email address contains', { expected: expectedWithUniq }); + flow().assertPrimaryEmailContains(expectedWithUniq); +}); + /** * @step Navigates to the Parent or guardian details section and validates the header text. * diff --git a/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.html b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.html new file mode 100644 index 0000000000..a84d690bf4 --- /dev/null +++ b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.html @@ -0,0 +1,26 @@ +
+ + +

{{ warningText }}

+ +
+ + +
+
diff --git a/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.spec.ts b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.spec.ts new file mode 100644 index 0000000000..586b7464eb --- /dev/null +++ b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.spec.ts @@ -0,0 +1,293 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router, convertToParamMap } from '@angular/router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FinesAccConvertComponent } from './fines-acc-convert.component'; +import { FinesAccPayloadService } from '../services/fines-acc-payload.service'; +import { FinesAccountStore } from '../stores/fines-acc.store'; +import { FINES_ACC_DEFENDANT_DETAILS_HEADER_MOCK } from '../fines-acc-defendant-details/mocks/fines-acc-defendant-details-header.mock'; +import { MOCK_FINES_ACCOUNT_STATE } from '../mocks/fines-acc-state.mock'; +import { FINES_ACC_MAP_TRANSFORM_ITEMS_CONFIG } from '../services/constants/fines-acc-map-transform-items-config.constant'; +import { FINES_ACC_DEFENDANT_ROUTING_PATHS } from '../routing/constants/fines-acc-defendant-routing-paths.constant'; +import { FINES_ACC_DEFENDANT_ROUTING_TITLES } from '../routing/constants/fines-acc-defendant-routing-titles.constant'; +import { FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES } from '../fines-acc-party-add-amend-convert/constants/fines-acc-party-add-amend-convert-party-types.constant'; +import { FINES_PERMISSIONS } from '@constants/fines-permissions.constant'; +import { routing } from '../routing/fines-acc.routes'; +import { TitleResolver } from '@hmcts/opal-frontend-common/resolvers/title'; +import { defendantAccountHeadingResolver } from '../routing/resolvers/defendant-account-heading.resolver'; +import { routePermissionsGuard } from '@hmcts/opal-frontend-common/guards/route-permissions'; +import { authGuard } from '@hmcts/opal-frontend-common/guards/auth'; +import { finesAccStateGuard } from '../routing/guards/fines-acc-state-guard/fines-acc-state.guard'; +import { FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES } from '../fines-acc-party-add-amend-convert/constants/fines-acc-party-add-amend-convert-modes.constant'; +import { IOpalFinesAccountDefendantDetailsHeader } from '../fines-acc-defendant-details/interfaces/fines-acc-defendant-details-header.interface'; + +describe('FinesAccConvertComponent', () => { + let fixture: ComponentFixture; + let component: FinesAccConvertComponent; + let mockRouter: { navigate: ReturnType }; + let mockActivatedRoute: ActivatedRoute; + let mockPayloadService: { + transformPayload: ReturnType; + transformAccountHeaderForStore: ReturnType; + }; + let mockAccountStore: { + setAccountState: ReturnType; + account_number: ReturnType; + party_name: ReturnType; + }; + + const defaultHeadingData: IOpalFinesAccountDefendantDetailsHeader = { + ...structuredClone(FINES_ACC_DEFENDANT_DETAILS_HEADER_MOCK), + debtor_type: 'Defendant', + party_details: { + ...structuredClone(FINES_ACC_DEFENDANT_DETAILS_HEADER_MOCK.party_details), + organisation_flag: false, + individual_details: { + title: 'Mr', + forenames: 'Terrence', + surname: 'CONWAY-JOHNSON', + date_of_birth: FINES_ACC_DEFENDANT_DETAILS_HEADER_MOCK.party_details.individual_details?.date_of_birth ?? null, + age: FINES_ACC_DEFENDANT_DETAILS_HEADER_MOCK.party_details.individual_details?.age ?? null, + national_insurance_number: + FINES_ACC_DEFENDANT_DETAILS_HEADER_MOCK.party_details.individual_details?.national_insurance_number ?? null, + individual_aliases: + FINES_ACC_DEFENDANT_DETAILS_HEADER_MOCK.party_details.individual_details?.individual_aliases ?? null, + }, + }, + }; + + const companyHeadingData: IOpalFinesAccountDefendantDetailsHeader = { + ...structuredClone(FINES_ACC_DEFENDANT_DETAILS_HEADER_MOCK), + debtor_type: 'Defendant', + party_details: { + ...structuredClone(FINES_ACC_DEFENDANT_DETAILS_HEADER_MOCK.party_details), + organisation_flag: true, + organisation_details: { + organisation_name: 'Accdetail comp limited', + organisation_aliases: [], + }, + individual_details: null, + }, + }; + + const configureRoute = ( + partyType = FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY, + headingData = defaultHeadingData, + ) => { + mockActivatedRoute.snapshot = { + data: { defendantAccountHeadingData: headingData }, + paramMap: convertToParamMap({ accountId: '123', partyType }), + } as never; + }; + + const createComponent = () => { + fixture = TestBed.createComponent(FinesAccConvertComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }; + + beforeEach(async () => { + mockRouter = { + navigate: vi.fn().mockName('Router.navigate'), + }; + + mockActivatedRoute = { snapshot: {} as never } as unknown as ActivatedRoute; + + mockPayloadService = { + transformPayload: vi.fn().mockName('FinesAccPayloadService.transformPayload'), + transformAccountHeaderForStore: vi.fn().mockName('FinesAccPayloadService.transformAccountHeaderForStore'), + }; + mockPayloadService.transformPayload.mockImplementation((payload) => payload); + mockPayloadService.transformAccountHeaderForStore.mockReturnValue(MOCK_FINES_ACCOUNT_STATE); + + mockAccountStore = { + setAccountState: vi.fn().mockName('FinesAccountStore.setAccountState'), + account_number: vi.fn().mockName('FinesAccountStore.account_number'), + party_name: vi.fn().mockName('FinesAccountStore.party_name'), + }; + mockAccountStore.account_number.mockReturnValue('06000427N'); + mockAccountStore.party_name.mockReturnValue('Mr Terrence CONWAY-JOHNSON'); + + configureRoute(); + + await TestBed.configureTestingModule({ + imports: [FinesAccConvertComponent], + providers: [ + { provide: Router, useValue: mockRouter }, + { provide: ActivatedRoute, useValue: mockActivatedRoute }, + { provide: FinesAccPayloadService, useValue: mockPayloadService }, + { provide: FinesAccountStore, useValue: mockAccountStore }, + ], + }).compileComponents(); + }); + + it('should configure the convert route with title, permission, and heading resolver', () => { + const defendantRoute = routing.find( + (route) => route.path === `${FINES_ACC_DEFENDANT_ROUTING_PATHS.root}/:accountId`, + ); + const convertRoute = defendantRoute?.children?.find( + (child) => child.path === `${FINES_ACC_DEFENDANT_ROUTING_PATHS.children.convert}/:partyType`, + ); + + expect(convertRoute?.path).toBe(`${FINES_ACC_DEFENDANT_ROUTING_PATHS.children.convert}/:partyType`); + expect(convertRoute?.canActivate).toEqual([authGuard, routePermissionsGuard, finesAccStateGuard]); + expect(convertRoute?.data).toEqual({ + routePermissionId: [FINES_PERMISSIONS['account-maintenance']], + title: FINES_ACC_DEFENDANT_ROUTING_TITLES.children.convert, + }); + expect(convertRoute?.resolve).toEqual({ + title: TitleResolver, + defendantAccountHeadingData: defendantAccountHeadingResolver, + }); + }); + + it('should configure the party route to use partyType and mode route params', () => { + const defendantRoute = routing.find( + (route) => route.path === `${FINES_ACC_DEFENDANT_ROUTING_PATHS.root}/:accountId`, + ); + const partyRoute = defendantRoute?.children?.find( + (child) => child.path === `${FINES_ACC_DEFENDANT_ROUTING_PATHS.children.party}/:partyType/:mode`, + ); + + expect(partyRoute?.path).toBe(`${FINES_ACC_DEFENDANT_ROUTING_PATHS.children.party}/:partyType/:mode`); + }); + + it('should create', () => { + createComponent(); + + expect(component).toBeTruthy(); + }); + + it('should hydrate account state from defendantAccountHeadingData', () => { + createComponent(); + + expect(mockPayloadService.transformPayload).toHaveBeenCalledWith( + defaultHeadingData, + FINES_ACC_MAP_TRANSFORM_ITEMS_CONFIG, + ); + expect(mockPayloadService.transformAccountHeaderForStore).toHaveBeenCalledWith( + 123, + defaultHeadingData, + 'defendant', + ); + expect(mockAccountStore.setAccountState).toHaveBeenCalledWith(MOCK_FINES_ACCOUNT_STATE); + }); + + it('should render the caption, heading, warning text, and action buttons for company conversion', () => { + createComponent(); + + const compiled = fixture.nativeElement as HTMLElement; + + expect(compiled.textContent).toContain('06000427N - Mr Terrence CONWAY-JOHNSON'); + expect(compiled.textContent).toContain('Are you sure you want to convert this account to a company account?'); + expect(compiled.textContent).toContain( + 'Certain data related to individual accounts, such as employment details, will be removed.', + ); + expect(compiled.textContent).toContain('Yes - continue'); + expect(compiled.textContent).toContain('No - cancel'); + }); + + it('should render the caption, heading, warning text, and action buttons for individual conversion', () => { + mockAccountStore.party_name.mockReturnValue('Accdetail comp limited'); + configureRoute(FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.INDIVIDUAL, companyHeadingData); + + createComponent(); + + const compiled = fixture.nativeElement as HTMLElement; + + expect(compiled.textContent).toContain('06000427N - Accdetail comp limited'); + expect(compiled.textContent).toContain('Are you sure you want to convert this account to an individual account?'); + expect(compiled.textContent).toContain( + 'Some information specific to company accounts, such as company name, will be removed.', + ); + expect(compiled.textContent).toContain('Yes - continue'); + expect(compiled.textContent).toContain('No - cancel'); + }); + + it('should navigate to the company details page when continue is clicked', () => { + createComponent(); + + component.handleContinue(); + + expect(mockRouter.navigate).toHaveBeenCalledWith( + [ + '../../', + FINES_ACC_DEFENDANT_ROUTING_PATHS.children.party, + FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY, + FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES.CONVERT, + ], + { + relativeTo: mockActivatedRoute, + }, + ); + }); + + it('should navigate to the defendant details page when continuing individual conversion', () => { + configureRoute(FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.INDIVIDUAL, companyHeadingData); + + createComponent(); + + component.handleContinue(); + + expect(mockRouter.navigate).toHaveBeenCalledWith( + [ + '../../', + FINES_ACC_DEFENDANT_ROUTING_PATHS.children.party, + FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.INDIVIDUAL, + FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES.CONVERT, + ], + { + relativeTo: mockActivatedRoute, + }, + ); + }); + + it('should navigate back to defendant details when cancel is clicked', () => { + createComponent(); + + component.navigateBackToAccountSummary(); + + expect(mockRouter.navigate).toHaveBeenCalledWith(['../../', FINES_ACC_DEFENDANT_ROUTING_PATHS.children.details], { + relativeTo: mockActivatedRoute, + fragment: 'defendant', + }); + }); + + it('should redirect back to defendant details when partyType is unsupported', () => { + configureRoute('unsupported-target'); + + createComponent(); + + expect(mockRouter.navigate).toHaveBeenCalledWith(['../../', FINES_ACC_DEFENDANT_ROUTING_PATHS.children.details], { + relativeTo: mockActivatedRoute, + fragment: 'defendant', + }); + }); + + it('should redirect back to defendant details when an individual account tries to convert to individual', () => { + configureRoute(FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.INDIVIDUAL, defaultHeadingData); + + createComponent(); + + expect(mockRouter.navigate).toHaveBeenCalledWith(['../../', FINES_ACC_DEFENDANT_ROUTING_PATHS.children.details], { + relativeTo: mockActivatedRoute, + fragment: 'defendant', + }); + }); + + it('should redirect back to defendant details when the account is already a company account', () => { + configureRoute(FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY, { + ...defaultHeadingData, + party_details: { + ...defaultHeadingData.party_details, + organisation_flag: true, + }, + }); + + createComponent(); + + expect(mockRouter.navigate).toHaveBeenCalledWith(['../../', FINES_ACC_DEFENDANT_ROUTING_PATHS.children.details], { + relativeTo: mockActivatedRoute, + fragment: 'defendant', + }); + }); +}); diff --git a/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.ts b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.ts new file mode 100644 index 0000000000..245502bd14 --- /dev/null +++ b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.ts @@ -0,0 +1,138 @@ +import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { GovukHeadingWithCaptionComponent } from '@hmcts/opal-frontend-common/components/govuk/govuk-heading-with-caption'; +import { GovukCancelLinkComponent } from '@hmcts/opal-frontend-common/components/govuk/govuk-cancel-link'; +import { FinesAccPayloadService } from '../services/fines-acc-payload.service'; +import { FinesAccountStore } from '../stores/fines-acc.store'; +import { IOpalFinesAccountDefendantDetailsHeader } from '../fines-acc-defendant-details/interfaces/fines-acc-defendant-details-header.interface'; +import { FINES_ACC_MAP_TRANSFORM_ITEMS_CONFIG } from '../services/constants/fines-acc-map-transform-items-config.constant'; +import { FINES_ACC_DEFENDANT_ROUTING_PATHS } from '../routing/constants/fines-acc-defendant-routing-paths.constant'; +import { FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES } from '../fines-acc-party-add-amend-convert/constants/fines-acc-party-add-amend-convert-party-types.constant'; +import { FINES_ACC_DEBTOR_TYPES } from '../constants/fines-acc-debtor-types.constant'; +import { FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES } from '../fines-acc-party-add-amend-convert/constants/fines-acc-party-add-amend-convert-modes.constant'; + +@Component({ + selector: 'app-fines-acc-convert', + imports: [GovukHeadingWithCaptionComponent, GovukCancelLinkComponent], + templateUrl: './fines-acc-convert.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FinesAccConvertComponent implements OnInit { + private readonly activatedRoute = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly payloadService = inject(FinesAccPayloadService); + + public readonly accountStore = inject(FinesAccountStore); + public readonly partyTypes = FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES; + public readonly routePartyType = this.activatedRoute.snapshot.paramMap.get('partyType') ?? ''; + public readonly accountId = Number(this.activatedRoute.snapshot.paramMap.get('accountId')); + + public accountData!: IOpalFinesAccountDefendantDetailsHeader; + + /** + * Hydrates the account header data from the route resolver and syncs it into store state. + */ + private getHeaderDataFromRoute(): void { + this.accountData = this.payloadService.transformPayload( + this.activatedRoute.snapshot.data['defendantAccountHeadingData'], + FINES_ACC_MAP_TRANSFORM_ITEMS_CONFIG, + ); + this.accountStore.setAccountState( + this.payloadService.transformAccountHeaderForStore(this.accountId, this.accountData, 'defendant'), + ); + } + + /** + * Indicates whether the source account currently represents a company. + */ + private get isSourceCompanyAccount(): boolean { + return this.accountData.party_details.organisation_flag; + } + + /** + * Validates whether the requested target party type is a supported conversion from the source account. + */ + private get canConvertAccount(): boolean { + if (this.routePartyType === this.partyTypes.COMPANY) { + return !this.isSourceCompanyAccount && this.accountData.debtor_type !== FINES_ACC_DEBTOR_TYPES.parentGuardian; + } + + if (this.routePartyType === this.partyTypes.INDIVIDUAL) { + return this.isSourceCompanyAccount; + } + + return false; + } + + /** + * Builds the account number and party name caption shown above the confirmation prompt. + */ + public get captionText(): string { + return `${this.accountStore.account_number() ?? ''} - ${this.accountStore.party_name() ?? ''}`; + } + + /** + * Returns the confirmation heading for the requested conversion target. + */ + public get headingText(): string { + if (this.routePartyType === this.partyTypes.INDIVIDUAL) { + return 'Are you sure you want to convert this account to an individual account?'; + } + + return 'Are you sure you want to convert this account to a company account?'; + } + + /** + * Returns the warning copy describing which source-specific fields will be removed. + */ + public get warningText(): string { + if (this.routePartyType === this.partyTypes.INDIVIDUAL) { + return 'Some information specific to company accounts, such as company name, will be removed.'; + } + + return 'Certain data related to individual accounts, such as employment details, will be removed.'; + } + + /** + * Initializes the page state and redirects back to account details when the requested conversion is not valid. + */ + public ngOnInit(): void { + this.getHeaderDataFromRoute(); + + if (!this.canConvertAccount) { + this.navigateBackToAccountSummary(); + } + } + + /** + * Continues to the shared convert form for the selected target party type. + */ + public handleContinue(): void { + if (!this.canConvertAccount) { + this.navigateBackToAccountSummary(); + return; + } + + this.router.navigate( + [ + '../../', + FINES_ACC_DEFENDANT_ROUTING_PATHS.children.party, + this.routePartyType, + FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES.CONVERT, + ], + { + relativeTo: this.activatedRoute, + }, + ); + } + + /** + * Returns the user to the defendant details tab from the conversion confirmation page. + */ + public navigateBackToAccountSummary(): void { + this.router.navigate(['../../', FINES_ACC_DEFENDANT_ROUTING_PATHS.children.details], { + relativeTo: this.activatedRoute, + fragment: 'defendant', + }); + } +} diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.html b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.html index 60c0df72f2..a58ff5773d 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.html +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.html @@ -29,13 +29,24 @@

Defendant Details

summaryListId="defendantDetails" > - + @if (convertAction) { +
+
+

Actions

+

+ @if (convertAction.interactive) { + {{ convertAction.label }} + } @else { + {{ convertAction.label }} + } +

+
+ } } diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.spec.ts b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.spec.ts index 720cddd768..3c3cfe5ef1 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.spec.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.spec.ts @@ -23,22 +23,65 @@ describe('FinesAccDefendantDetailsAtAGlanceTabComponent', () => { expect(component).toBeTruthy(); }); - it('should handle convert account click when partyType is a company', () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.spyOn(component.convertAccount, 'emit'); - component.tabData.defendant_account_party.party_details.organisation_flag = true; - component.handleConvertAccount(); - expect(component.convertAccount.emit).toHaveBeenCalledWith(FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY); + it('should not render the actions column when no convert action is configured', () => { + fixture.componentRef.setInput('hasAccountMaintenencePermission', false); + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + + expect(compiled.textContent).not.toContain('Actions'); + expect(compiled.textContent).not.toContain('Convert to a company account'); + expect(compiled.textContent).not.toContain('Convert to an individual account'); }); - it('should handle convert account click when partyType is an individual', () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.spyOn(component.convertAccount, 'emit'); - component.tabData.defendant_account_party.party_details.organisation_flag = false; - component.handleConvertAccount(); - expect(component.convertAccount.emit).toHaveBeenCalledWith( - FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.INDIVIDUAL, - ); + it('should render an interactive convert-to-company action for paying individual accounts with permission', () => { + fixture.componentRef.setInput('hasAccountMaintenencePermission', true); + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + + expect(compiled.textContent).toContain('Actions'); + expect(compiled.textContent).toContain('Convert to a company account'); + }); + + it('should render an interactive convert-to-individual action for paying company accounts with permission', () => { + fixture.componentRef.setInput('hasAccountMaintenencePermission', true); + fixture.componentRef.setInput('tabData', { + ...structuredClone(OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_MOCK), + defendant_account_party: { + ...structuredClone(OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_MOCK.defendant_account_party), + party_details: { + ...structuredClone(OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_MOCK.defendant_account_party.party_details), + organisation_flag: true, + }, + }, + }); + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + const convertLink = fixture.nativeElement.querySelector('.govuk-link'); + + expect(compiled.textContent).toContain('Actions'); + expect(compiled.textContent).toContain('Convert to an individual account'); + expect(convertLink).not.toBeNull(); + }); + + it('should not render a convert action for non-paying accounts', () => { + fixture.componentRef.setInput('hasAccountMaintenencePermission', true); + fixture.componentRef.setInput('tabData', { + ...structuredClone(OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_MOCK), + defendant_account_party: { + ...structuredClone(OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_MOCK.defendant_account_party), + is_debtor: false, + }, + }); + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + + expect(compiled.textContent).not.toContain('Actions'); + expect(compiled.textContent).not.toContain('Convert to a company account'); + expect(compiled.textContent).not.toContain('Convert to an individual account'); }); it('should handle change defendant details when partyType is a company', () => { @@ -60,4 +103,38 @@ describe('FinesAccDefendantDetailsAtAGlanceTabComponent', () => { FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.INDIVIDUAL, ); }); + + it('should emit the company party type when the convert link is clicked', () => { + fixture.componentRef.setInput('hasAccountMaintenencePermission', true); + fixture.detectChanges(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(component.convertAccount, 'emit'); + component.handleConvertAccount(); + + expect(component.convertAccount.emit).toHaveBeenCalledWith(FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY); + }); + + it('should emit the individual party type when the company convert link is clicked', () => { + fixture.componentRef.setInput('hasAccountMaintenencePermission', true); + fixture.componentRef.setInput('tabData', { + ...structuredClone(OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_MOCK), + defendant_account_party: { + ...structuredClone(OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_MOCK.defendant_account_party), + party_details: { + ...structuredClone(OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_MOCK.defendant_account_party.party_details), + organisation_flag: true, + }, + }, + }); + fixture.detectChanges(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(component.convertAccount, 'emit'); + component.handleConvertAccount(); + + expect(component.convertAccount.emit).toHaveBeenCalledWith( + FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.INDIVIDUAL, + ); + }); }); diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.ts b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.ts index adf8c85427..656514a23a 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.ts @@ -4,6 +4,7 @@ import { FINES_ACC_SUMMARY_TABS_CONTENT_STYLES } from '../../constants/fines-acc import { IOpalFinesAccountDefendantAccountParty } from '@services/fines/opal-fines-service/interfaces/opal-fines-account-defendant-account-party.interface'; import { FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES } from '../../fines-acc-party-add-amend-convert/constants/fines-acc-party-add-amend-convert-party-types.constant'; import { FinesAccPartyDetails } from '../fines-acc-party-details/fines-acc-party-details.component'; +import { IFinesAccDefendantDetailsConvertAction } from '../interfaces/fines-acc-defendant-details-convert-action.interface'; @Component({ selector: 'app-fines-acc-defendant-details-defendant-tab', @@ -18,11 +19,29 @@ export class FinesAccDefendantDetailsDefendantTabComponent { @Output() changeDefendantDetails = new EventEmitter(); @Output() convertAccount = new EventEmitter(); - public handleConvertAccount(): void { + public get convertAction(): IFinesAccDefendantDetailsConvertAction | null { + if (!this.hasAccountMaintenencePermission || !this.tabData.defendant_account_party.is_debtor) { + return null; + } + if (this.tabData.defendant_account_party.party_details.organisation_flag) { - this.convertAccount.emit(FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY); - } else { - this.convertAccount.emit(FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.INDIVIDUAL); + return { + interactive: true, + label: 'Convert to an individual account', + partyType: FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.INDIVIDUAL, + }; + } + + return { + interactive: true, + label: 'Convert to a company account', + partyType: FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY, + }; + } + + public handleConvertAccount(): void { + if (this.convertAction?.interactive) { + this.convertAccount.emit(this.convertAction.partyType); } } 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 045f4d7578..e2344ad711 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 @@ -163,7 +163,7 @@

Business Unit:

(addComments)="navigateToAddCommentsPage()" [style]="tabContentStyles" (changeDefendantDetails)="navigateToAmendPartyDetailsPage($event)" - (convertAccount)="navigateToAmendPartyDetailsPage($event)" + (convertAccount)="navigateToConvertAccountPage($event)" > } } 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 befe3bd353..9a5d97538c 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 @@ -248,6 +248,20 @@ describe('FinesAccDefendantDetailsComponent', () => { ); }); + it('should navigate to the company convert page when convert is triggered', () => { + routerSpy.navigate.mockClear(); + component.navigateToConvertAccountPage(FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY); + + expect(routerSpy.navigate).toHaveBeenCalledWith( + [ + `../${FINES_ACC_DEFENDANT_ROUTING_PATHS.children.convert}/${FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY}`, + ], + { + relativeTo: component['activatedRoute'], + }, + ); + }); + it('should navigate to access-denied if user lacks permission for the add account note page', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.spyOn(component['permissionsService'], 'hasBusinessUnitPermissionAccess').mockReturnValue(false); @@ -276,6 +290,30 @@ describe('FinesAccDefendantDetailsComponent', () => { }); }); + it('should navigate to access-denied if user lacks permission for convert action', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(component['permissionsService'], 'hasBusinessUnitPermissionAccess').mockReturnValue(false); + component.navigateToConvertAccountPage(FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY); + + expect(routerSpy.navigate).toHaveBeenCalledWith(['/access-denied'], { + relativeTo: component['activatedRoute'], + }); + }); + + it('should navigate to the individual convert page when company convert is triggered', () => { + routerSpy.navigate.mockClear(); + component.navigateToConvertAccountPage(FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.INDIVIDUAL); + + expect(routerSpy.navigate).toHaveBeenCalledWith( + [ + `../${FINES_ACC_DEFENDANT_ROUTING_PATHS.children.convert}/${FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.INDIVIDUAL}`, + ], + { + relativeTo: component['activatedRoute'], + }, + ); + }); + it('should navigate to the change defendant payment terms access denied page if user does not have the relevant permission', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.spyOn(component['permissionsService'], 'hasBusinessUnitPermissionAccess').mockReturnValue(false); 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 f6b136cc90..d5c8dd87b0 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 @@ -179,6 +179,14 @@ export class FinesAccDefendantDetailsComponent extends AbstractTabData implement } } + private hasAccountMaintenancePermissionInBusinessUnit(): boolean { + return this.permissionsService.hasBusinessUnitPermissionAccess( + FINES_PERMISSIONS['account-maintenance'], + Number(this.accountStore.business_unit_id()!), + this.userState.business_unit_users, + ); + } + /** * Initializes and sets up the observable data stream for the fines draft tab component. * @@ -391,13 +399,7 @@ export class FinesAccDefendantDetailsComponent extends AbstractTabData implement * @param partyType */ public navigateToAmendPartyDetailsPage(partyType: string): void { - if ( - this.permissionsService.hasBusinessUnitPermissionAccess( - FINES_PERMISSIONS['account-maintenance'], - Number(this.accountStore.business_unit_id()!), - this.userState.business_unit_users, - ) - ) { + if (this.hasAccountMaintenancePermissionInBusinessUnit()) { this['router'].navigate([`../party/${partyType}/amend`], { relativeTo: this.activatedRoute, }); @@ -408,6 +410,18 @@ export class FinesAccDefendantDetailsComponent extends AbstractTabData implement } } + public navigateToConvertAccountPage(targetPartyType: string): void { + if (this.hasAccountMaintenancePermissionInBusinessUnit() && targetPartyType) { + this['router'].navigate([`../${FINES_ACC_DEFENDANT_ROUTING_PATHS.children.convert}/${targetPartyType}`], { + relativeTo: this.activatedRoute, + }); + } else { + this['router'].navigate(['/access-denied'], { + relativeTo: this.activatedRoute, + }); + } + } + /** * Navigates to the amend payment terms page or amend denied page based on user permissions and account status. */ diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/interfaces/fines-acc-defendant-details-convert-action.interface.ts b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/interfaces/fines-acc-defendant-details-convert-action.interface.ts new file mode 100644 index 0000000000..3c22eba45a --- /dev/null +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/interfaces/fines-acc-defendant-details-convert-action.interface.ts @@ -0,0 +1,5 @@ +export interface IFinesAccDefendantDetailsConvertAction { + interactive: boolean; + label: string; + partyType: string; +} diff --git a/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/constants/fines-acc-party-add-amend-convert-modes.constant.ts b/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/constants/fines-acc-party-add-amend-convert-modes.constant.ts new file mode 100644 index 0000000000..f46b46867d --- /dev/null +++ b/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/constants/fines-acc-party-add-amend-convert-modes.constant.ts @@ -0,0 +1,4 @@ +export const FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES = { + AMEND: 'amend', + CONVERT: 'convert', +} as const; diff --git a/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert-form/fines-acc-party-add-amend-convert-form.component.ts b/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert-form/fines-acc-party-add-amend-convert-form.component.ts index 58d1e38342..791fc81ece 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert-form/fines-acc-party-add-amend-convert-form.component.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert-form/fines-acc-party-add-amend-convert-form.component.ts @@ -57,6 +57,7 @@ import { FinesAccPartyAddAmendConvertCd } from './components/fines-acc-party-add import { FinesAccPartyAddAmendConvertVd } from './components/fines-acc-party-add-amend-convert-vd/fines-acc-party-add-amend-convert-vd.component'; import { FinesAccPartyAddAmendConvertDobNi } from './components/fines-acc-party-add-amend-convert-dob-ni/fines-acc-party-add-amend-convert-dob-ni.component'; import { FINES_ACC_SUMMARY_TABS_CONTENT_STYLES } from '../../constants/fines-acc-summary-tabs-content-styles.constant'; +import { FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES } from '../constants/fines-acc-party-add-amend-convert-modes.constant'; const LETTERS_WITH_SPACES_PATTERN_VALIDATOR = patternValidator(LETTERS_WITH_SPACES_PATTERN, 'lettersWithSpacesPattern'); const ALPHANUMERIC_WITH_HYPHENS_SPACES_APOSTROPHES_DOT_PATTERN_VALIDATOR = patternValidator( @@ -108,6 +109,7 @@ export class FinesAccPartyAddAmendConvertFormComponent @Input({ required: true }) public isDebtor!: boolean; @Input({ required: true }) public partyType!: string; + @Input({ required: false }) public mode: string = FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES.AMEND; @Input({ required: false }) public initialFormData: IFinesAccPartyAddAmendConvertForm = FINES_ACC_PARTY_ADD_AMEND_CONVERT_FORM; override fieldErrors: IFinesAccPartyAddAmendConvertFieldErrors = { diff --git a/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert.component.html b/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert.component.html index 268577bbfe..d9d52d0b39 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert.component.html +++ b/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert.component.html @@ -1,6 +1,7 @@
{ let component: FinesAccPartyAddAmendConvert; @@ -27,11 +28,26 @@ describe('FinesAccPartyAddAmendConvert', () => { account_number: Mock; party_name: Mock; welsh_speaking: Mock; + setSuccessMessage: Mock; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any let mockUtilsService: any; // eslint-disable-next-line @typescript-eslint/no-explicit-any let mockRouter: any; + let mockActivatedRoute: { + snapshot: { + data: { + partyAddAmendConvertData: typeof OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_EMPTY_DATA_MOCK & { + version: string; + }; + }; + params: { + partyType: string; + accountId: string; + mode: string; + }; + }; + }; beforeEach(async () => { mockPayloadService = { @@ -50,6 +66,7 @@ describe('FinesAccPartyAddAmendConvert', () => { account_number: vi.fn().mockReturnValue('12345ABC'), party_name: vi.fn().mockReturnValue('John Doe'), welsh_speaking: vi.fn().mockReturnValue('Yes'), + setSuccessMessage: vi.fn(), }; mockUtilsService = { scrollToTop: vi.fn().mockName('UtilsService.scrollToTop'), @@ -57,6 +74,21 @@ describe('FinesAccPartyAddAmendConvert', () => { mockRouter = { navigate: vi.fn().mockName('Router.navigate'), }; + mockActivatedRoute = { + snapshot: { + data: { + partyAddAmendConvertData: { + ...OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_EMPTY_DATA_MOCK, + version: '1', + }, + }, + params: { + partyType: 'individual', + accountId: '123', + mode: FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES.AMEND, + }, + }, + }; // Set up default return values mockPayloadService.mapDebtorAccountPartyPayload.mockReturnValue( @@ -75,23 +107,7 @@ describe('FinesAccPartyAddAmendConvert', () => { { provide: FinesAccountStore, useValue: mockFinesAccStore }, { provide: UtilsService, useValue: mockUtilsService }, { provide: Router, useValue: mockRouter }, - { - provide: ActivatedRoute, - useValue: { - snapshot: { - data: { - partyAddAmendConvertData: { - ...OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_EMPTY_DATA_MOCK, - version: '1', - }, - }, - params: { - partyType: 'individual', - accountId: '123', - }, - }, - }, - }, + { provide: ActivatedRoute, useValue: mockActivatedRoute }, ], }).compileComponents(); @@ -104,6 +120,19 @@ describe('FinesAccPartyAddAmendConvert', () => { expect(component).toBeTruthy(); }); + it('should read amend mode from the route by default', () => { + expect(component['mode']).toBe(FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES.AMEND); + }); + + it('should read convert mode from the route', () => { + mockActivatedRoute.snapshot.params.mode = FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES.CONVERT; + fixture = TestBed.createComponent(FinesAccPartyAddAmendConvert); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(component['mode']).toBe(FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES.CONVERT); + }); + it('should handle form submission for individual party type', () => { // Arrange const mockFormData = { @@ -192,6 +221,7 @@ describe('FinesAccPartyAddAmendConvert', () => { // Assert expect(mockOpalFinesService.clearCache).toHaveBeenCalledWith('defendantAccountPartyCache$'); + expect(mockFinesAccStore.setSuccessMessage).not.toHaveBeenCalled(); expect(mockRouter.navigate).toHaveBeenCalledWith(['details'], { relativeTo: undefined, fragment: 'defendant', @@ -220,12 +250,47 @@ describe('FinesAccPartyAddAmendConvert', () => { // Assert expect(mockOpalFinesService.clearCache).toHaveBeenCalledWith('defendantAccountPartyCache$'); + expect(mockFinesAccStore.setSuccessMessage).not.toHaveBeenCalled(); expect(mockRouter.navigate).toHaveBeenCalledWith(['details'], { relativeTo: undefined, fragment: 'parent-or-guardian', }); }); + it('should set a success message when converting to a company account', () => { + const mockFormData = { + formData: MOCK_EMPTY_FINES_ACC_PARTY_ADD_AMEND_CONVERT_FORM_DATA.formData, + nestedFlow: false, + }; + + Object.defineProperty(component, 'mode', { + value: FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES.CONVERT, + writable: true, + }); + Object.defineProperty(component, 'partyType', { value: 'company', writable: true }); + + component.handleFormSubmit(mockFormData); + + expect(mockFinesAccStore.setSuccessMessage).toHaveBeenCalledWith('Converted to a company account.'); + }); + + it('should set a success message when converting to an individual account', () => { + const mockFormData = { + formData: MOCK_EMPTY_FINES_ACC_PARTY_ADD_AMEND_CONVERT_FORM_DATA.formData, + nestedFlow: false, + }; + + Object.defineProperty(component, 'mode', { + value: FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES.CONVERT, + writable: true, + }); + Object.defineProperty(component, 'partyType', { value: 'individual', writable: true }); + + component.handleFormSubmit(mockFormData); + + expect(mockFinesAccStore.setSuccessMessage).toHaveBeenCalledWith('Converted to an individual account.'); + }); + it('should redirect to details page when required store values are missing', () => { const mockFormData = { formData: MOCK_EMPTY_FINES_ACC_PARTY_ADD_AMEND_CONVERT_FORM_DATA.formData, diff --git a/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert.component.ts b/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert.component.ts index 2d68b7d5a1..32c5c0dea2 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert.component.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert.component.ts @@ -9,6 +9,7 @@ import { OpalFines } from '../../services/opal-fines-service/opal-fines.service' import { FinesAccountStore } from '../stores/fines-acc.store'; import { UtilsService } from '@hmcts/opal-frontend-common/services/utils-service'; import { FINES_ACC_DEFENDANT_ROUTING_PATHS } from '../routing/constants/fines-acc-defendant-routing-paths.constant'; +import { FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES } from './constants/fines-acc-party-add-amend-convert-modes.constant'; @Component({ selector: 'app-fines-acc-debtor-add-amend', imports: [FinesAccPartyAddAmendConvertFormComponent], @@ -24,6 +25,8 @@ export class FinesAccPartyAddAmendConvert extends AbstractFormParentBaseComponen protected readonly finesDefendantRoutingPaths = FINES_ACC_DEFENDANT_ROUTING_PATHS; protected readonly partyType: string = this['activatedRoute'].snapshot.params['partyType']; + protected readonly mode: string = + this['activatedRoute'].snapshot.params['mode'] ?? FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES.AMEND; protected readonly prefilledFormData: IFinesAccPartyAddAmendConvertForm = { formData: this.payloadService.mapDebtorAccountPartyPayload( this.partyPayload, @@ -35,6 +38,22 @@ export class FinesAccPartyAddAmendConvert extends AbstractFormParentBaseComponen protected readonly isDebtor: boolean = this.partyPayload.defendant_account_party.is_debtor; protected readonly fragment = this.partyType === 'parentGuardian' ? 'parent-or-guardian' : 'defendant'; + private get successMessage(): string | null { + if (this.mode !== FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES.CONVERT) { + return null; + } + + if (this.partyType === 'company') { + return 'Converted to a company account.'; + } + + if (this.partyType === 'individual') { + return 'Converted to an individual account.'; + } + + return null; + } + /** * Handles the form submission event from the child form component. * @param formData - The form data submitted from the child component @@ -70,7 +89,12 @@ export class FinesAccPartyAddAmendConvert extends AbstractFormParentBaseComponen ) .subscribe({ next: () => { + const successMessage = this.successMessage; + this.opalFinesService.clearCache('defendantAccountPartyCache$'); + if (successMessage) { + this.finesAccStore.setSuccessMessage(successMessage); + } this.routerNavigate( this.finesDefendantRoutingPaths.children.details, false, diff --git a/src/app/flows/fines/fines-acc/routing/constants/fines-acc-defendant-routing-paths.constant.ts b/src/app/flows/fines/fines-acc/routing/constants/fines-acc-defendant-routing-paths.constant.ts index 8c6b36df90..12bd89d66f 100644 --- a/src/app/flows/fines/fines-acc/routing/constants/fines-acc-defendant-routing-paths.constant.ts +++ b/src/app/flows/fines/fines-acc/routing/constants/fines-acc-defendant-routing-paths.constant.ts @@ -4,6 +4,7 @@ export const FINES_ACC_DEFENDANT_ROUTING_PATHS: IFinesAccDefendantRoutingPaths = root: 'defendant', children: { details: 'details', + convert: 'convert', note: 'note', comments: 'comments', debtor: 'debtor', diff --git a/src/app/flows/fines/fines-acc/routing/constants/fines-acc-defendant-routing-titles.constant.ts b/src/app/flows/fines/fines-acc/routing/constants/fines-acc-defendant-routing-titles.constant.ts index f1d8b7bd04..393beb8af4 100644 --- a/src/app/flows/fines/fines-acc/routing/constants/fines-acc-defendant-routing-titles.constant.ts +++ b/src/app/flows/fines/fines-acc/routing/constants/fines-acc-defendant-routing-titles.constant.ts @@ -4,6 +4,7 @@ export const FINES_ACC_DEFENDANT_ROUTING_TITLES: IFinesAccDefendantRoutingPaths root: 'Defendant', children: { details: 'Account details', + convert: 'Convert account', note: 'Account notes', comments: 'Account comments', debtor: 'Change debtor details', diff --git a/src/app/flows/fines/fines-acc/routing/fines-acc.routes.ts b/src/app/flows/fines/fines-acc/routing/fines-acc.routes.ts index acf3c220ec..07ecea48ef 100644 --- a/src/app/flows/fines/fines-acc/routing/fines-acc.routes.ts +++ b/src/app/flows/fines/fines-acc/routing/fines-acc.routes.ts @@ -48,6 +48,18 @@ export const routing: Routes = [ }, resolve: { title: TitleResolver, defendantAccountHeadingData: defendantAccountHeadingResolver }, }, + { + path: `${FINES_ACC_DEFENDANT_ROUTING_PATHS.children.convert}/:partyType`, + + loadComponent: () => + import('../fines-acc-convert/fines-acc-convert.component').then((c) => c.FinesAccConvertComponent), + canActivate: [authGuard, routePermissionsGuard, finesAccStateGuard], + data: { + routePermissionId: [accRootPermissionIds['account-maintenance']], + title: FINES_ACC_DEFENDANT_ROUTING_TITLES.children.convert, + }, + resolve: { title: TitleResolver, defendantAccountHeadingData: defendantAccountHeadingResolver }, + }, { path: `${FINES_ACC_DEFENDANT_ROUTING_PATHS.children.note}/add`, @@ -118,7 +130,7 @@ export const routing: Routes = [ }, }, { - path: `${FINES_ACC_DEFENDANT_ROUTING_PATHS.children['party']}/:partyType/amend`, + path: `${FINES_ACC_DEFENDANT_ROUTING_PATHS.children['party']}/:partyType/:mode`, loadComponent: () => import('../fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert.component').then( diff --git a/src/app/flows/fines/fines-acc/routing/interfaces/fines-acc-defendant-routing-paths.interface.ts b/src/app/flows/fines/fines-acc/routing/interfaces/fines-acc-defendant-routing-paths.interface.ts index cc514a485e..eb0216a298 100644 --- a/src/app/flows/fines/fines-acc/routing/interfaces/fines-acc-defendant-routing-paths.interface.ts +++ b/src/app/flows/fines/fines-acc/routing/interfaces/fines-acc-defendant-routing-paths.interface.ts @@ -4,6 +4,7 @@ export interface IFinesAccDefendantRoutingPaths extends IChildRoutingPaths { root: string; children: { details: string; + convert: string; note: string; comments: string; debtor: string; diff --git a/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.spec.ts b/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.spec.ts index 86226176a5..2f2541bcee 100644 --- a/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.spec.ts +++ b/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.spec.ts @@ -809,8 +809,7 @@ describe('transformDefendantAccountPartyPayload', () => { expect(result.facc_party_add_amend_convert_organisation_aliases).toEqual([]); }); - it('should handle company data with individual flag correctly when partyType is specified', () => { - // Test edge case: organisation_flag is false but partyType is "company" + it('should ignore organisation-only fields when the source is flagged as individual', () => { const mockMixedData = { ...mockDefendantData, defendant_account_party: { @@ -828,10 +827,124 @@ describe('transformDefendantAccountPartyPayload', () => { const result = transformDefendantAccountPartyPayload(mockMixedData, 'company', true); - // Should respect partyType parameter over organisation_flag - expect(result.facc_party_add_amend_convert_organisation_name).toBe('Override Company'); + // Organisation-only fields should not be carried across from an individual source record. + expect(result.facc_party_add_amend_convert_organisation_name).toBeNull(); + expect(result.facc_party_add_amend_convert_organisation_aliases).toEqual([]); + expect(result.facc_party_add_amend_convert_add_alias).toBe(false); + expect(result.facc_party_add_amend_convert_title).toBeNull(); + expect(result.facc_party_add_amend_convert_individual_aliases).toEqual([]); + }); + + it('should preserve only the shared company-compatible fields when converting an individual account to company', () => { + const result = transformDefendantAccountPartyPayload(mockDefendantData, 'company', true); + + expect(result.facc_party_add_amend_convert_organisation_name).toBeNull(); + expect(result.facc_party_add_amend_convert_title).toBeNull(); + expect(result.facc_party_add_amend_convert_forenames).toBeNull(); + expect(result.facc_party_add_amend_convert_surname).toBeNull(); + expect(result.facc_party_add_amend_convert_dob).toBeNull(); + expect(result.facc_party_add_amend_convert_national_insurance_number).toBeNull(); + expect(result.facc_party_add_amend_convert_individual_aliases).toEqual([]); + expect(result.facc_party_add_amend_convert_employer_company_name).toBeNull(); + + expect(result.facc_party_add_amend_convert_address_line_1).toBe('45 High Street'); + expect(result.facc_party_add_amend_convert_address_line_2).toBe('Flat 2B'); + expect(result.facc_party_add_amend_convert_address_line_3).toBeNull(); + expect(result.facc_party_add_amend_convert_post_code).toBe('AB1 2CD'); + expect(result.facc_party_add_amend_convert_contact_email_address_1).toBe('sarah.thompson@example.com'); + expect(result.facc_party_add_amend_convert_contact_email_address_2).toBe('sarah.t@example.com'); + expect(result.facc_party_add_amend_convert_contact_telephone_number_mobile).toBe('07123 456789'); + expect(result.facc_party_add_amend_convert_contact_telephone_number_home).toBe('01234 567890'); + expect(result.facc_party_add_amend_convert_contact_telephone_number_business).toBe('09876 543210'); + expect(result.facc_party_add_amend_convert_vehicle_make).toBe('Ford Focus'); + expect(result.facc_party_add_amend_convert_vehicle_registration_mark).toBe('XY21 ABC'); + }); + + it('should preserve only shared fields when converting a company account to individual', () => { + const mockCompanyData = { + ...mockDefendantData, + defendant_account_party: { + ...mockDefendantData.defendant_account_party, + party_details: { + ...mockDefendantData.defendant_account_party.party_details, + organisation_flag: true, + organisation_details: { + organisation_name: 'Convert Me Ltd', + organisation_aliases: [ + { + alias_id: 'ORG-1', + sequence_number: 1, + organisation_name: 'Convert Alias Ltd', + }, + ], + }, + individual_details: null, + }, + employer_details: null, + }, + }; + + const result = transformDefendantAccountPartyPayload(mockCompanyData, 'individual', true); + + expect(result.facc_party_add_amend_convert_organisation_name).toBeNull(); + expect(result.facc_party_add_amend_convert_organisation_aliases).toEqual([]); + expect(result.facc_party_add_amend_convert_add_alias).toBe(false); + expect(result.facc_party_add_amend_convert_title).toBeNull(); + expect(result.facc_party_add_amend_convert_forenames).toBeNull(); + expect(result.facc_party_add_amend_convert_surname).toBeNull(); + expect(result.facc_party_add_amend_convert_dob).toBeNull(); + expect(result.facc_party_add_amend_convert_national_insurance_number).toBeNull(); expect(result.facc_party_add_amend_convert_individual_aliases).toEqual([]); + expect(result.facc_party_add_amend_convert_employer_company_name).toBeNull(); + expect(result.facc_party_add_amend_convert_employer_reference).toBeNull(); + expect(result.facc_party_add_amend_convert_employer_email_address).toBeNull(); + expect(result.facc_party_add_amend_convert_employer_telephone_number).toBeNull(); + + expect(result.facc_party_add_amend_convert_address_line_1).toBe('45 High Street'); + expect(result.facc_party_add_amend_convert_address_line_2).toBe('Flat 2B'); + expect(result.facc_party_add_amend_convert_post_code).toBe('AB1 2CD'); + expect(result.facc_party_add_amend_convert_contact_email_address_1).toBe('sarah.thompson@example.com'); + expect(result.facc_party_add_amend_convert_contact_email_address_2).toBe('sarah.t@example.com'); + expect(result.facc_party_add_amend_convert_contact_telephone_number_mobile).toBe('07123 456789'); + expect(result.facc_party_add_amend_convert_contact_telephone_number_home).toBe('01234 567890'); + expect(result.facc_party_add_amend_convert_contact_telephone_number_business).toBe('09876 543210'); + expect(result.facc_party_add_amend_convert_vehicle_make).toBe('Ford Focus'); + expect(result.facc_party_add_amend_convert_vehicle_registration_mark).toBe('XY21 ABC'); + }); + + it('should trim non-debtor-only fields when converting a non-debtor company account to individual', () => { + const mockCompanyData = { + ...mockDefendantData, + defendant_account_party: { + ...mockDefendantData.defendant_account_party, + is_debtor: false, + party_details: { + ...mockDefendantData.defendant_account_party.party_details, + organisation_flag: true, + organisation_details: { + organisation_name: 'Convert Me Ltd', + organisation_aliases: [], + }, + individual_details: null, + }, + employer_details: null, + }, + }; + + const result = transformDefendantAccountPartyPayload(mockCompanyData, 'individual', false); + + expect(result.facc_party_add_amend_convert_address_line_1).toBe('45 High Street'); + expect(result.facc_party_add_amend_convert_post_code).toBe('AB1 2CD'); + expect(result.facc_party_add_amend_convert_contact_email_address_1).toBeNull(); + expect(result.facc_party_add_amend_convert_contact_email_address_2).toBeNull(); + expect(result.facc_party_add_amend_convert_contact_telephone_number_mobile).toBeNull(); + expect(result.facc_party_add_amend_convert_contact_telephone_number_home).toBeNull(); + expect(result.facc_party_add_amend_convert_contact_telephone_number_business).toBeNull(); + expect(result.facc_party_add_amend_convert_vehicle_make).toBeNull(); + expect(result.facc_party_add_amend_convert_vehicle_registration_mark).toBeNull(); + expect(result.facc_party_add_amend_convert_language_preferences_document_language).toBeNull(); + expect(result.facc_party_add_amend_convert_language_preferences_hearing_language).toBeNull(); }); }); }); diff --git a/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.ts b/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.ts index 889545d0ac..534ff38f36 100644 --- a/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.ts +++ b/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.ts @@ -212,38 +212,45 @@ export const transformDefendantAccountPartyPayload = ( const { organisation_flag } = party_details; const individualDetails = party_details.individual_details; const organisationDetails = party_details.organisation_details; + const isCompany = partyType === 'company'; + const isIndividual = partyType === 'individual'; + const isParentGuardian = partyType === 'parentGuardian'; + const hasExplicitPartyType = isCompany || isIndividual || isParentGuardian; - // Handle aliases based on party type + // Map source aliases separately so each target branch can decide whether to keep them. let individualAliases: IFinesAccPartyAddAmendConvertIndividualAliasState[] = []; let organisationAliases: IFinesAccPartyAddAmendConvertOrganisationAliasState[] = []; - let hasAliases = false; + let hasIndividualAliases = false; + let hasOrganisationAliases = false; if (organisation_flag && organisationDetails?.organisation_aliases) { organisationAliases = mapOrganisationAliasesToArrayStructure(organisationDetails.organisation_aliases); - hasAliases = organisationDetails.organisation_aliases.length > 0; + hasOrganisationAliases = organisationDetails.organisation_aliases.length > 0; } else if (!organisation_flag && individualDetails?.individual_aliases) { individualAliases = mapIndividualAliasesToArrayStructure(individualDetails.individual_aliases); - hasAliases = individualDetails.individual_aliases.length > 0; + hasIndividualAliases = individualDetails.individual_aliases.length > 0; } - const isCompany = partyType === 'company'; - const isIndividual = partyType === 'individual'; - // Create base state with common fields const baseState = createBaseState(address, contact_details, vehicle_details, language_preferences); - if (isCompany || organisation_flag) { - return getCompanyParty(baseState, organisationDetails, organisationAliases, hasAliases); + if (isCompany || (!hasExplicitPartyType && organisation_flag)) { + return getCompanyParty( + baseState, + organisation_flag ? organisationDetails : null, + organisationAliases, + hasOrganisationAliases, + ); } else if (isIndividual && !isDebtor) { // For individual party type that is not a debtor, only show fields from title to address postcode - return getIndividualDebtorParty(baseState, individualDetails, individualAliases, hasAliases); + return getIndividualDebtorParty(baseState, individualDetails, individualAliases, hasIndividualAliases); } else { // For parent/guardian or individual debtor, show all fields including employer details return getIndividualOrParentGuardianParty( baseState, individualDetails, individualAliases, - hasAliases, + hasIndividualAliases, employer_details, ); }