diff --git a/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.html b/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.html
index 2253459973..a933fe1a41 100644
--- a/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.html
+++ b/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.html
@@ -1,7 +1,7 @@
@if (selectedOffenceConfirmation) {
- @if (offenceCode.count === 1) {
+ @if (matchedOffenceTitle; as offenceTitle) {
} @else {
diff --git a/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.spec.ts b/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.spec.ts
index 5e6bc74881..6ba16798ec 100644
--- a/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.spec.ts
+++ b/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.spec.ts
@@ -3,6 +3,10 @@ import { NgTemplateOutlet } from '@angular/common';
import { MojTicketPanelComponent } from '@hmcts/opal-frontend-common/components/moj/moj-ticket-panel';
import { FinesMacOffenceCodeHintComponent } from './fines-mac-offence-code-hint.component';
import { IOpalFinesOffencesRefData } from '@services/fines/opal-fines-service/interfaces/opal-fines-offences-ref-data.interface';
+import {
+ OPAL_FINES_OFFENCES_REF_DATA_DUPLICATE_CODE_MOCK,
+ OPAL_FINES_OFFENCES_REF_DATA_EXACT_MATCH_MULTI_RESULT_MOCK,
+} from '@services/fines/opal-fines-service/mocks/opal-fines-offences-ref-data-multi-result.mock';
import { OPAL_FINES_OFFENCES_REF_DATA_MOCK } from '@services/fines/opal-fines-service/mocks/opal-fines-offences-ref-data.mock';
import { beforeEach, describe, expect, it } from 'vitest';
@@ -53,6 +57,7 @@ describe('FinesMacOffenceCodeHintComponent', () => {
it('should render component with both inputs provided', () => {
fixture.componentRef.setInput('offenceCode', mockOffenceCode);
+ fixture.componentRef.setInput('searchedOffenceCode', 'AK123456');
fixture.componentRef.setInput('selectedOffenceConfirmation', true);
fixture.detectChanges();
@@ -70,6 +75,7 @@ describe('FinesMacOffenceCodeHintComponent', () => {
it('should maintain component state through input changes', () => {
// Initial state
fixture.componentRef.setInput('offenceCode', mockOffenceCode);
+ fixture.componentRef.setInput('searchedOffenceCode', 'AK123456');
fixture.componentRef.setInput('selectedOffenceConfirmation', false);
fixture.detectChanges();
@@ -82,4 +88,38 @@ describe('FinesMacOffenceCodeHintComponent', () => {
expect(component.selectedOffenceConfirmation).toBe(true);
expect(component.offenceCode).toEqual(mockOffenceCode);
});
+
+ it('should render Offence found when the searched code matches one result exactly', () => {
+ fixture.componentRef.setInput('offenceCode', OPAL_FINES_OFFENCES_REF_DATA_EXACT_MATCH_MULTI_RESULT_MOCK);
+ fixture.componentRef.setInput('searchedOffenceCode', 'CD71039');
+ fixture.componentRef.setInput('selectedOffenceConfirmation', true);
+ fixture.detectChanges();
+
+ const textContent = fixture.nativeElement.textContent;
+ expect(textContent).toContain('Offence found');
+ expect(textContent).toContain('Criminal damage to property valued under £5000');
+ });
+
+ it('should resolve a duplicate code match using the saved offence id', () => {
+ fixture.componentRef.setInput('offenceCode', OPAL_FINES_OFFENCES_REF_DATA_DUPLICATE_CODE_MOCK);
+ fixture.componentRef.setInput('offenceId', 41800);
+ fixture.componentRef.setInput('searchedOffenceCode', 'GMMET001');
+ fixture.componentRef.setInput('selectedOffenceConfirmation', true);
+ fixture.detectChanges();
+
+ const textContent = fixture.nativeElement.textContent;
+ expect(textContent).toContain('Offence found');
+ expect(textContent).toContain('Duplicate offence title B');
+ });
+
+ it('should render Offence not found when there is no exact code match', () => {
+ fixture.componentRef.setInput('offenceCode', mockOffenceCode);
+ fixture.componentRef.setInput('searchedOffenceCode', 'AK12345');
+ fixture.componentRef.setInput('selectedOffenceConfirmation', true);
+ fixture.detectChanges();
+
+ const textContent = fixture.nativeElement.textContent;
+ expect(textContent).toContain('Offence not found');
+ expect(textContent).toContain('Enter a valid offence code');
+ });
});
diff --git a/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.ts b/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.ts
index 71b6ee902d..9cb550817a 100644
--- a/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.ts
+++ b/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.ts
@@ -1,7 +1,8 @@
import { NgTemplateOutlet } from '@angular/common';
-import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
+import { ChangeDetectionStrategy, Component, Input, inject } from '@angular/core';
import { MojTicketPanelComponent } from '@hmcts/opal-frontend-common/components/moj/moj-ticket-panel';
import { IOpalFinesOffencesRefData } from '@services/fines/opal-fines-service/interfaces/opal-fines-offences-ref-data.interface';
+import { FinesMacOffenceDetailsService } from '../../fines-mac-offence-details/services/fines-mac-offence-details.service';
@Component({
selector: 'app-fines-mac-offence-code-hint',
@@ -10,6 +11,21 @@ import { IOpalFinesOffencesRefData } from '@services/fines/opal-fines-service/in
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FinesMacOffenceCodeHintComponent {
+ private readonly offenceDetailsService = inject(FinesMacOffenceDetailsService);
+
@Input() public offenceCode!: IOpalFinesOffencesRefData;
+ @Input() public offenceId: number | null = null;
+ @Input() public searchedOffenceCode: string | null = null;
@Input() public selectedOffenceConfirmation!: boolean;
+
+ /**
+ * Returns the title for a single exact offence-code match from the lookup response.
+ * @returns The matched offence title, or `null` when the code is missing, ambiguous, or not found.
+ */
+ public get matchedOffenceTitle(): string | null {
+ return (
+ this.offenceDetailsService.findExactOffenceMatch(this.offenceCode, this.searchedOffenceCode, this.offenceId)
+ ?.offence_title ?? null
+ );
+ }
}
diff --git a/src/app/flows/fines/fines-mac/fines-mac-fixed-penalty-details/fines-mac-fixed-penalty-details-form/fines-mac-fixed-penalty-details-form.component.html b/src/app/flows/fines/fines-mac/fines-mac-fixed-penalty-details/fines-mac-fixed-penalty-details-form/fines-mac-fixed-penalty-details-form.component.html
index 575b3f9cf9..500d620492 100644
--- a/src/app/flows/fines/fines-mac/fines-mac-fixed-penalty-details/fines-mac-fixed-penalty-details-form/fines-mac-fixed-penalty-details-form.component.html
+++ b/src/app/flows/fines/fines-mac/fines-mac-fixed-penalty-details/fines-mac-fixed-penalty-details-form/fines-mac-fixed-penalty-details-form.component.html
@@ -266,6 +266,8 @@
Offence Details
@if (offenceCode$ | async; as offenceCode) {
}
diff --git a/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-add-an-offence/fines-mac-offence-details-add-an-offence-form/fines-mac-offence-details-add-an-offence-form.component.html b/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-add-an-offence/fines-mac-offence-details-add-an-offence-form/fines-mac-offence-details-add-an-offence-form.component.html
index 10a03ccf0b..9fd2529d05 100644
--- a/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-add-an-offence/fines-mac-offence-details-add-an-offence-form/fines-mac-offence-details-add-an-offence-form.component.html
+++ b/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-add-an-offence/fines-mac-offence-details-add-an-offence-form/fines-mac-offence-details-add-an-offence-form.component.html
@@ -61,6 +61,8 @@
@if (offenceCode$ | async; as offenceCode) {
}
diff --git a/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-review-offence/fines-mac-offence-details-review-offence-heading/fines-mac-offence-details-review-offence-heading-title/fines-mac-offence-details-review-offence-heading-title.component.spec.ts b/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-review-offence/fines-mac-offence-details-review-offence-heading/fines-mac-offence-details-review-offence-heading-title/fines-mac-offence-details-review-offence-heading-title.component.spec.ts
index 8d220e125d..8148310f0e 100644
--- a/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-review-offence/fines-mac-offence-details-review-offence-heading/fines-mac-offence-details-review-offence-heading-title/fines-mac-offence-details-review-offence-heading-title.component.spec.ts
+++ b/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-review-offence/fines-mac-offence-details-review-offence-heading/fines-mac-offence-details-review-offence-heading-title/fines-mac-offence-details-review-offence-heading-title.component.spec.ts
@@ -1,6 +1,10 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FinesMacOffenceDetailsReviewOffenceHeadingTitleComponent } from './fines-mac-offence-details-review-offence-heading-title.component';
+import {
+ OPAL_FINES_OFFENCES_REF_DATA_DUPLICATE_CODE_MOCK,
+ OPAL_FINES_OFFENCES_REF_DATA_EXACT_MATCH_MULTI_RESULT_MOCK,
+} from '@services/fines/opal-fines-service/mocks/opal-fines-offences-ref-data-multi-result.mock';
import { OPAL_FINES_OFFENCES_REF_DATA_SINGULAR_MOCK } from '@services/fines/opal-fines-service/mocks/opal-fines-offences-ref-data-singular.mock';
import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -16,6 +20,7 @@ describe('FinesMacOffenceDetailsReviewOffenceHeadingTitleComponent', () => {
fixture = TestBed.createComponent(FinesMacOffenceDetailsReviewOffenceHeadingTitleComponent);
component = fixture.componentInstance;
+ component.offenceCode = OPAL_FINES_OFFENCES_REF_DATA_SINGULAR_MOCK.refData[0].get_cjs_code;
component.offenceRefData = OPAL_FINES_OFFENCES_REF_DATA_SINGULAR_MOCK;
fixture.detectChanges();
@@ -40,4 +45,33 @@ describe('FinesMacOffenceDetailsReviewOffenceHeadingTitleComponent', () => {
expect(component.offenceTitle).toEqual(component.offenceRefData.refData[0].offence_title);
});
+
+ it('should use the exact code match when multiple offences are returned', () => {
+ component.offenceCode = 'CD71039';
+ component.offenceRefData = OPAL_FINES_OFFENCES_REF_DATA_EXACT_MATCH_MULTI_RESULT_MOCK;
+
+ component.getOffenceTitle();
+
+ expect(component.offenceTitle).toEqual('Criminal damage to property valued under £5000');
+ });
+
+ it('should use the saved offence id when duplicate code matches are returned', () => {
+ component.offenceCode = 'GMMET001';
+ component.offenceId = 41800;
+ component.offenceRefData = OPAL_FINES_OFFENCES_REF_DATA_DUPLICATE_CODE_MOCK;
+
+ component.getOffenceTitle();
+
+ expect(component.offenceTitle).toEqual('Duplicate offence title B');
+ });
+
+ it('should fall back to the first offence title when duplicate code matches are returned without a saved offence id', () => {
+ component.offenceCode = 'GMMET001';
+ component.offenceId = null;
+ component.offenceRefData = OPAL_FINES_OFFENCES_REF_DATA_DUPLICATE_CODE_MOCK;
+
+ component.getOffenceTitle();
+
+ expect(component.offenceTitle).toEqual('Duplicate offence title A');
+ });
});
diff --git a/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-review-offence/fines-mac-offence-details-review-offence-heading/fines-mac-offence-details-review-offence-heading-title/fines-mac-offence-details-review-offence-heading-title.component.ts b/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-review-offence/fines-mac-offence-details-review-offence-heading/fines-mac-offence-details-review-offence-heading-title/fines-mac-offence-details-review-offence-heading-title.component.ts
index 9885656ab9..214d7e8802 100644
--- a/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-review-offence/fines-mac-offence-details-review-offence-heading/fines-mac-offence-details-review-offence-heading-title/fines-mac-offence-details-review-offence-heading-title.component.ts
+++ b/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-review-offence/fines-mac-offence-details-review-offence-heading/fines-mac-offence-details-review-offence-heading-title/fines-mac-offence-details-review-offence-heading-title.component.ts
@@ -1,11 +1,12 @@
import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
+import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core';
import { GovukHeadingWithCaptionComponent } from '@hmcts/opal-frontend-common/components/govuk/govuk-heading-with-caption';
import {
GovukSummaryListRowActionItemComponent,
GovukSummaryListRowActionsComponent,
} from '@hmcts/opal-frontend-common/components/govuk/govuk-summary-list';
import { IOpalFinesOffencesRefData } from '@services/fines/opal-fines-service/interfaces/opal-fines-offences-ref-data.interface';
+import { FinesMacOffenceDetailsService } from '../../../services/fines-mac-offence-details.service';
@Component({
selector: 'app-fines-mac-offence-details-review-offence-heading-title',
@@ -20,7 +21,10 @@ import { IOpalFinesOffencesRefData } from '@services/fines/opal-fines-service/in
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FinesMacOffenceDetailsReviewOffenceHeadingTitleComponent implements OnInit {
+ private readonly offenceDetailsService = inject(FinesMacOffenceDetailsService);
+
@Input({ required: true }) public offenceCode!: string;
+ @Input({ required: false }) public offenceId: number | null = null;
@Input({ required: true }) public offenceRefData!: IOpalFinesOffencesRefData;
@Input({ required: false }) public showActions!: boolean;
@Input({ required: false }) public showDetails: boolean = true;
@@ -41,7 +45,12 @@ export class FinesMacOffenceDetailsReviewOffenceHeadingTitleComponent implements
* Retrieves the offence title from the offence reference data and assigns it to the `offenceTitle` property.
*/
public getOffenceTitle(): void {
- this.offenceTitle = this.offenceRefData.refData[0].offence_title;
+ const exactMatch = this.offenceDetailsService.findExactOffenceMatch(
+ this.offenceRefData,
+ this.offenceCode,
+ this.offenceId,
+ );
+ this.offenceTitle = exactMatch?.offence_title ?? this.offenceRefData.refData[0]?.offence_title ?? '';
}
public ngOnInit(): void {
diff --git a/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-review-offence/fines-mac-offence-details-review-offence-heading/fines-mac-offence-details-review-offence-heading.component.html b/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-review-offence/fines-mac-offence-details-review-offence-heading/fines-mac-offence-details-review-offence-heading.component.html
index edc3d9cf6a..b371ad8e4b 100644
--- a/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-review-offence/fines-mac-offence-details-review-offence-heading/fines-mac-offence-details-review-offence-heading.component.html
+++ b/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-review-offence/fines-mac-offence-details-review-offence-heading/fines-mac-offence-details-review-offence-heading.component.html
@@ -1,6 +1,7 @@
@if (offenceRefData$ | async; as offenceRefData) {
{
expect(result[0].formData.fm_offence_details_impositions[0]).toEqual(expected);
});
+ it('findExactOffenceMatch - should return undefined when duplicate code matches are ambiguous', () => {
+ const result = service.findExactOffenceMatch(OPAL_FINES_OFFENCES_REF_DATA_DUPLICATE_CODE_MOCK, 'GMMET001');
+
+ expect(result).toBeUndefined();
+ });
+
+ it('findExactOffenceMatch - should resolve duplicate code matches using the saved offence id', () => {
+ const result = service.findExactOffenceMatch(OPAL_FINES_OFFENCES_REF_DATA_DUPLICATE_CODE_MOCK, 'GMMET001', 41800);
+
+ expect(result).toEqual(OPAL_FINES_OFFENCES_REF_DATA_DUPLICATE_CODE_MOCK.refData[1]);
+ });
+
describe('initOffenceListener', () => {
let form: FormGroup;
let destroy$: Subject;
@@ -116,7 +132,7 @@ describe('FinesMacOffenceDetailsService', () => {
form = new FormGroup({
code: new FormControl(''),
- id: new FormControl(''),
+ id: new FormControl(null),
});
destroy$ = new Subject();
@@ -132,7 +148,7 @@ describe('FinesMacOffenceDetailsService', () => {
it('should call populateHint immediately if initial code is present', () => {
vi.useFakeTimers();
- form.get('code')?.setValue('ab12345');
+ form.get('code')?.setValue('ak123456');
service.initOffenceCodeListener(
form,
@@ -166,13 +182,13 @@ describe('FinesMacOffenceDetailsService', () => {
onConfirmChangeSpy,
);
- form.get('code')?.setValue('xy98765');
+ form.get('code')?.setValue('ak123456');
form.get('code')?.updateValueAndValidity();
vi.advanceTimersByTime(FINES_MAC_OFFENCE_DETAILS_DEFAULT_VALUES.defaultDebounceTime);
- expect(uppercaseAllLettersSpy).toHaveBeenCalledWith('xy98765');
- expect(form.get('code')?.value).toBe('XY98765');
+ expect(uppercaseAllLettersSpy).toHaveBeenCalledWith('ak123456');
+ expect(form.get('code')?.value).toBe('AK123456');
expect(form.get('id')?.value).toBe(314441);
expect(onResultSpy).toHaveBeenCalled();
expect(onConfirmChangeSpy).toHaveBeenCalledWith(true);
@@ -201,6 +217,148 @@ describe('FinesMacOffenceDetailsService', () => {
expect(onConfirmChangeSpy).toHaveBeenCalledWith(true);
});
+ it('should populate the offence id when an exact match exists in a multi-result response', () => {
+ vi.useFakeTimers();
+ getOffenceByCjsCode = () => of(OPAL_FINES_OFFENCES_REF_DATA_EXACT_MATCH_MULTI_RESULT_MOCK);
+
+ service.initOffenceCodeListener(
+ form,
+ 'code',
+ 'id',
+ destroy$,
+ getOffenceByCjsCode,
+ onResultSpy,
+ onConfirmChangeSpy,
+ );
+
+ form.get('code')?.setValue('cd71039');
+ vi.advanceTimersByTime(FINES_MAC_OFFENCE_DETAILS_DEFAULT_VALUES.defaultDebounceTime);
+
+ expect(form.get('code')?.errors).toBeNull();
+ expect(form.get('id')?.value).toBe(41799);
+ expect(onConfirmChangeSpy).toHaveBeenCalledWith(true);
+ });
+
+ it('should mark duplicate exact code matches as invalid when there is no saved offence id', () => {
+ vi.useFakeTimers();
+ getOffenceByCjsCode = () => of(OPAL_FINES_OFFENCES_REF_DATA_DUPLICATE_CODE_MOCK);
+
+ service.initOffenceCodeListener(
+ form,
+ 'code',
+ 'id',
+ destroy$,
+ getOffenceByCjsCode,
+ onResultSpy,
+ onConfirmChangeSpy,
+ );
+
+ form.get('code')?.setValue('gmmet001');
+ vi.advanceTimersByTime(FINES_MAC_OFFENCE_DETAILS_DEFAULT_VALUES.defaultDebounceTime);
+
+ expect(form.get('code')?.errors).toEqual({ invalidOffenceCode: true });
+ expect(form.get('id')?.value).toBeNull();
+ });
+
+ it('should preserve the saved offence id when the original duplicate code is re-entered', () => {
+ vi.useFakeTimers();
+ form.get('code')?.setValue('GMMET001');
+ form.get('id')?.setValue(41800);
+ getOffenceByCjsCode = (code: string) => {
+ if (code === 'UNIQUE01') {
+ return of({
+ count: 1,
+ refData: [
+ {
+ offence_id: 99999,
+ get_cjs_code: 'UNIQUE01',
+ business_unit_id: 52,
+ offence_title: 'Unique offence title',
+ offence_title_cy: null,
+ date_used_from: '1997-11-16T00:00:00Z',
+ date_used_to: null,
+ offence_oas: 'Unique offence',
+ offence_oas_cy: null,
+ },
+ ],
+ });
+ }
+
+ return of(OPAL_FINES_OFFENCES_REF_DATA_DUPLICATE_CODE_MOCK);
+ };
+
+ service.initOffenceCodeListener(
+ form,
+ 'code',
+ 'id',
+ destroy$,
+ getOffenceByCjsCode,
+ onResultSpy,
+ onConfirmChangeSpy,
+ );
+
+ vi.advanceTimersByTime(FINES_MAC_OFFENCE_DETAILS_DEFAULT_VALUES.defaultDebounceTime);
+ expect(form.get('id')?.value).toBe(41800);
+
+ form.get('code')?.setValue('UNIQUE01');
+ vi.advanceTimersByTime(FINES_MAC_OFFENCE_DETAILS_DEFAULT_VALUES.defaultDebounceTime);
+ expect(form.get('id')?.value).toBe(99999);
+
+ form.get('code')?.setValue('GMMET001');
+ vi.advanceTimersByTime(FINES_MAC_OFFENCE_DETAILS_DEFAULT_VALUES.defaultDebounceTime);
+
+ expect(form.get('code')?.errors).toBeNull();
+ expect(form.get('id')?.value).toBe(41800);
+ });
+
+ it('should mark code as invalid when results are returned but none match exactly', () => {
+ vi.useFakeTimers();
+ const nonExactResponse: IOpalFinesOffencesRefData = {
+ count: 2,
+ refData: [
+ {
+ offence_id: 1,
+ get_cjs_code: 'TEST123A',
+ business_unit_id: 52,
+ offence_title: 'Test A',
+ offence_title_cy: null,
+ date_used_from: '1971-01-01T00:00:00Z',
+ date_used_to: null,
+ offence_oas: 'Test A',
+ offence_oas_cy: null,
+ },
+ {
+ offence_id: 2,
+ get_cjs_code: 'TEST123B',
+ business_unit_id: 52,
+ offence_title: 'Test B',
+ offence_title_cy: null,
+ date_used_from: '1971-01-01T00:00:00Z',
+ date_used_to: null,
+ offence_oas: 'Test B',
+ offence_oas_cy: null,
+ },
+ ],
+ };
+ getOffenceByCjsCode = () => of(nonExactResponse);
+
+ service.initOffenceCodeListener(
+ form,
+ 'code',
+ 'id',
+ destroy$,
+ getOffenceByCjsCode,
+ onResultSpy,
+ onConfirmChangeSpy,
+ );
+
+ form.get('code')?.setValue('TEST123');
+ vi.advanceTimersByTime(FINES_MAC_OFFENCE_DETAILS_DEFAULT_VALUES.defaultDebounceTime);
+
+ expect(form.get('code')?.errors).toEqual({ invalidOffenceCode: true });
+ expect(form.get('id')?.value).toBeNull();
+ });
+
it('should not call populateHint for short code', () => {
vi.useFakeTimers();
service.initOffenceCodeListener(
diff --git a/src/app/flows/fines/fines-mac/fines-mac-offence-details/services/fines-mac-offence-details.service.ts b/src/app/flows/fines/fines-mac/fines-mac-offence-details/services/fines-mac-offence-details.service.ts
index 3519764552..3347230759 100644
--- a/src/app/flows/fines/fines-mac/fines-mac-offence-details/services/fines-mac-offence-details.service.ts
+++ b/src/app/flows/fines/fines-mac/fines-mac-offence-details/services/fines-mac-offence-details.service.ts
@@ -4,6 +4,7 @@ import { FormGroup } from '@angular/forms';
import { debounceTime, distinctUntilChanged, Observable, Subject, takeUntil, tap } from 'rxjs';
import { FINES_MAC_OFFENCE_DETAILS_DEFAULT_VALUES } from '../constants/fines-mac-offence-details-default-values.constant';
import { UtilsService } from '@hmcts/opal-frontend-common/services/utils-service';
+import { IOpalFinesOffences } from '@services/fines/opal-fines-service/interfaces/opal-fines-offences.interface';
import { IOpalFinesOffencesRefData } from '@services/fines/opal-fines-service/interfaces/opal-fines-offences-ref-data.interface';
@Injectable({
@@ -55,6 +56,16 @@ export class FinesMacOffenceDetailsService {
}
}
+ /**
+ * Extracts the offence code from a ref-data item.
+ *
+ * @param offence - The offence ref-data item.
+ * @returns The offence code when present.
+ */
+ private getOffenceCode(offence: IOpalFinesOffences & { cjs_code?: string }): string | undefined {
+ return offence.get_cjs_code ?? offence.cjs_code;
+ }
+
/**
* Removes an imposition from the specified offence in the data array.
*
@@ -115,13 +126,49 @@ export class FinesMacOffenceDetailsService {
});
}
+ /**
+ * Finds the exact offence match for a supplied offence code from the returned offence reference data.
+ *
+ * Supports both `get_cjs_code` and `cjs_code` shaped response objects so the caller does not need
+ * to care about the source format.
+ *
+ * @param response - The offence lookup response.
+ * @param offenceCode - The offence code entered by the user.
+ * @param offenceId - Optional saved offence ID used to disambiguate duplicate exact-code matches.
+ * @returns The unique exact-code match, the saved offence when duplicates can be disambiguated, or `undefined`.
+ */
+ public findExactOffenceMatch(
+ response: IOpalFinesOffencesRefData | null | undefined,
+ offenceCode: string | null | undefined,
+ offenceId?: number | null,
+ ): IOpalFinesOffences | undefined {
+ if (!response?.refData?.length || !offenceCode) {
+ return undefined;
+ }
+
+ const normalisedOffenceCode = offenceCode.trim().toUpperCase();
+ const exactCodeMatches = response.refData.filter((offence) => {
+ const returnedCode = this.getOffenceCode(offence as IOpalFinesOffences & { cjs_code?: string });
+ return returnedCode?.trim().toUpperCase() === normalisedOffenceCode;
+ });
+
+ if (exactCodeMatches.length === 1) {
+ return exactCodeMatches[0];
+ }
+
+ if (offenceId == null) {
+ return undefined;
+ }
+
+ return exactCodeMatches.find((offence) => offence.offence_id === offenceId);
+ }
+
/**
* Initializes the offence code listener for a form control.
* @param form - The FormGroup containing the controls.
* @param codeControlName - The name of the control for the offence code.
* @param idControlName - The name of the control for the offence ID.
* @param destroy$ - Subject to signal when to unsubscribe from observables.
- * @param changeDetector - ChangeDetectorRef to trigger change detection.
* @param onResult - Optional callback function to handle the result of the code lookup.
* @param onConfirmChange - Optional callback function to confirm if the code change was successful.
*/
@@ -137,15 +184,21 @@ export class FinesMacOffenceDetailsService {
): void {
const codeControl = form.controls[codeControlName];
const idControl = form.controls[idControlName];
+ const savedOffenceId = idControl.value;
+ const savedOffenceCode = typeof codeControl.value === 'string' ? codeControl.value.trim().toUpperCase() : null;
const populateHint = (code: string) => {
- idControl.setValue(null);
+ const normalisedCode = code?.trim().toUpperCase();
+ const previousOffenceId =
+ normalisedCode && savedOffenceCode === normalisedCode ? savedOffenceId : idControl.value;
if (code?.length >= 7 && code?.length <= 8) {
const result$ = getOffenceByCjsCode(code).pipe(
tap((response) => {
- codeControl.setErrors(response.count === 0 ? { invalidOffenceCode: true } : null, { emitEvent: false });
- idControl.setValue(response.count === 1 ? response.refData[0].offence_id : null, { emitEvent: false });
+ const exactMatch = this.findExactOffenceMatch(response, code, previousOffenceId);
+
+ codeControl.setErrors(exactMatch ? null : { invalidOffenceCode: true }, { emitEvent: false });
+ idControl.setValue(exactMatch?.offence_id ?? null, { emitEvent: false });
if (typeof onResult === 'function') {
onResult(response);
@@ -157,6 +210,7 @@ export class FinesMacOffenceDetailsService {
result$.subscribe();
if (onConfirmChange) onConfirmChange(true);
} else if (onConfirmChange) {
+ idControl.setValue(null, { emitEvent: false });
onConfirmChange(false);
}
};
diff --git a/src/app/flows/fines/fines-mac/fines-mac-review-account/fines-mac-review-account-fixed-penalty-offence-details/fines-mac-review-account-fixed-penalty-offence-details.component.spec.ts b/src/app/flows/fines/fines-mac/fines-mac-review-account/fines-mac-review-account-fixed-penalty-offence-details/fines-mac-review-account-fixed-penalty-offence-details.component.spec.ts
index 44a9ed904b..c2c6433196 100644
--- a/src/app/flows/fines/fines-mac/fines-mac-review-account/fines-mac-review-account-fixed-penalty-offence-details/fines-mac-review-account-fixed-penalty-offence-details.component.spec.ts
+++ b/src/app/flows/fines/fines-mac/fines-mac-review-account/fines-mac-review-account-fixed-penalty-offence-details/fines-mac-review-account-fixed-penalty-offence-details.component.spec.ts
@@ -2,6 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FinesMacReviewAccountFixedPenaltyOffenceDetailsComponent } from './fines-mac-review-account-fixed-penalty-offence-details.component';
import { OpalFines } from '@services/fines/opal-fines-service/opal-fines.service';
+import { OPAL_FINES_OFFENCES_REF_DATA_DUPLICATE_CODE_MOCK } from '@services/fines/opal-fines-service/mocks/opal-fines-offences-ref-data-multi-result.mock';
import { OPAL_FINES_OFFENCES_REF_DATA_SINGULAR_MOCK } from '@services/fines/opal-fines-service/mocks/opal-fines-offences-ref-data-singular.mock';
import { of } from 'rxjs';
import { FINES_MAC_FIXED_PENALTY_DETAILS_STORE_STATE_MOCK } from '../../fines-mac-fixed-penalty-details/mocks/fines-mac-fixed-penalty-details-store-state.mock';
@@ -24,7 +25,7 @@ describe('FinesMacReviewAccountFixedPenaltyDetailsComponent', () => {
fixture = TestBed.createComponent(FinesMacReviewAccountFixedPenaltyOffenceDetailsComponent);
component = fixture.componentInstance;
- component.offenceDetails = FINES_MAC_FIXED_PENALTY_DETAILS_STORE_STATE_MOCK;
+ component.offenceDetails = structuredClone(FINES_MAC_FIXED_PENALTY_DETAILS_STORE_STATE_MOCK);
fixture.detectChanges();
});
@@ -43,9 +44,31 @@ describe('FinesMacReviewAccountFixedPenaltyDetailsComponent', () => {
});
it('should get offence details by code', () => {
- const offenceCode = '12345';
+ const offenceCode = 'AK123456';
component.getOffence(offenceCode);
expect(mockOpalFinesService.getOffenceByCjsCode).toHaveBeenCalledWith(offenceCode);
- expect(component.offence).toBe('ak test (12345)');
+ expect(component.offence).toBe('ak test (AK123456)');
+ });
+
+ it('should use the saved offence id when duplicate offences are returned', () => {
+ component.offenceDetails.fm_offence_details_offence_id = 41800;
+ mockOpalFinesService.getOffenceByCjsCode = vi
+ .fn()
+ .mockReturnValue(of(OPAL_FINES_OFFENCES_REF_DATA_DUPLICATE_CODE_MOCK));
+
+ component.getOffence('GMMET001');
+
+ expect(component.offence).toBe('Duplicate offence title B (GMMET001)');
+ });
+
+ it('should fall back to the first offence title when duplicate offences are returned without a saved offence id', () => {
+ component.offenceDetails.fm_offence_details_offence_id = null;
+ mockOpalFinesService.getOffenceByCjsCode = vi
+ .fn()
+ .mockReturnValue(of(OPAL_FINES_OFFENCES_REF_DATA_DUPLICATE_CODE_MOCK));
+
+ component.getOffence('GMMET001');
+
+ expect(component.offence).toBe('Duplicate offence title A (GMMET001)');
});
});
diff --git a/src/app/flows/fines/fines-mac/fines-mac-review-account/fines-mac-review-account-fixed-penalty-offence-details/fines-mac-review-account-fixed-penalty-offence-details.component.ts b/src/app/flows/fines/fines-mac/fines-mac-review-account/fines-mac-review-account-fixed-penalty-offence-details/fines-mac-review-account-fixed-penalty-offence-details.component.ts
index 0d76828312..2741859055 100644
--- a/src/app/flows/fines/fines-mac/fines-mac-review-account/fines-mac-review-account-fixed-penalty-offence-details/fines-mac-review-account-fixed-penalty-offence-details.component.ts
+++ b/src/app/flows/fines/fines-mac/fines-mac-review-account/fines-mac-review-account-fixed-penalty-offence-details/fines-mac-review-account-fixed-penalty-offence-details.component.ts
@@ -13,6 +13,7 @@ import { FINES_DEFAULT_VALUES } from '../../../constants/fines-default-values.co
import { FinesNotProvidedComponent } from '../../../components/fines-not-provided/fines-not-provided.component';
import { DateFormatPipe } from '@hmcts/opal-frontend-common/pipes/date-format';
import { MonetaryPipe } from '@hmcts/opal-frontend-common/pipes/monetary';
+import { FinesMacOffenceDetailsService } from '../../fines-mac-offence-details/services/fines-mac-offence-details.service';
@Component({
selector: 'app-fines-mac-review-account-fixed-penalty-offence-details',
@@ -30,6 +31,7 @@ import { MonetaryPipe } from '@hmcts/opal-frontend-common/pipes/monetary';
})
export class FinesMacReviewAccountFixedPenaltyOffenceDetailsComponent implements OnInit {
private readonly opalFinesService = inject(OpalFines);
+ private readonly offenceDetailsService = inject(FinesMacOffenceDetailsService);
@Input({ required: true }) public offenceDetails!: IFinesMacFixedPenaltyDetailsStoreState;
@Input({ required: false }) public isReadOnly = false;
@Output() public emitChangeOffenceDetails = new EventEmitter();
@@ -56,7 +58,13 @@ export class FinesMacReviewAccountFixedPenaltyOffenceDetailsComponent implements
*/
public getOffence(offenceCode: string): void {
this.opalFinesService.getOffenceByCjsCode(offenceCode).subscribe((offence: IOpalFinesOffencesRefData) => {
- this.offence = `${offence.refData[0].offence_title} (${offenceCode})`;
+ const exactMatch = this.offenceDetailsService.findExactOffenceMatch(
+ offence,
+ offenceCode,
+ this.offenceDetails.fm_offence_details_offence_id,
+ );
+ const offenceTitle = exactMatch?.offence_title ?? offence.refData[0]?.offence_title ?? offenceCode;
+ this.offence = `${offenceTitle} (${offenceCode})`;
});
}
diff --git a/src/app/flows/fines/services/opal-fines-service/mocks/opal-fines-offences-ref-data-multi-result.mock.ts b/src/app/flows/fines/services/opal-fines-service/mocks/opal-fines-offences-ref-data-multi-result.mock.ts
new file mode 100644
index 0000000000..bfb8c36fab
--- /dev/null
+++ b/src/app/flows/fines/services/opal-fines-service/mocks/opal-fines-offences-ref-data-multi-result.mock.ts
@@ -0,0 +1,79 @@
+import { IOpalFinesOffencesRefData } from '../interfaces/opal-fines-offences-ref-data.interface';
+
+export const OPAL_FINES_OFFENCES_REF_DATA_EXACT_MATCH_MULTI_RESULT_MOCK: IOpalFinesOffencesRefData = {
+ count: 4,
+ refData: [
+ {
+ offence_id: 41799,
+ get_cjs_code: 'CD71039',
+ business_unit_id: 52,
+ offence_title: 'Criminal damage to property valued under £5000',
+ offence_title_cy: null,
+ date_used_from: '1997-11-16T00:00:00Z',
+ date_used_to: null,
+ offence_oas: 'Contrary to sections 1(1) and 4 of the Criminal Damage Act 1971.',
+ offence_oas_cy: null,
+ },
+ {
+ offence_id: 30733,
+ get_cjs_code: 'CD71039A',
+ business_unit_id: 52,
+ offence_title: 'Attempt criminal damage to property valued under £5000',
+ offence_title_cy: null,
+ date_used_from: '1971-01-01T00:00:00Z',
+ date_used_to: null,
+ offence_oas: 'Contrary to section 1(1) of the Criminal Attempts Act 1981.',
+ offence_oas_cy: null,
+ },
+ {
+ offence_id: 30734,
+ get_cjs_code: 'CD71039B',
+ business_unit_id: 52,
+ offence_title: 'Aid, abet, counsel and procure damage under £5000',
+ offence_title_cy: null,
+ date_used_from: '1971-01-01T00:00:00Z',
+ date_used_to: null,
+ offence_oas: 'Contrary to sections 1(1) and 4 of the Criminal Damage Act 1971.',
+ offence_oas_cy: null,
+ },
+ {
+ offence_id: 30735,
+ get_cjs_code: 'CD71039C',
+ business_unit_id: 52,
+ offence_title: 'Conspiracy to destroy or damage property under £5000',
+ offence_title_cy: null,
+ date_used_from: '1971-01-01T00:00:00Z',
+ date_used_to: '2004-12-25T00:00:00Z',
+ offence_oas: 'Contrary to section 1 of the Criminal Law Act 1977.',
+ offence_oas_cy: null,
+ },
+ ],
+};
+
+export const OPAL_FINES_OFFENCES_REF_DATA_DUPLICATE_CODE_MOCK: IOpalFinesOffencesRefData = {
+ count: 2,
+ refData: [
+ {
+ offence_id: 41799,
+ get_cjs_code: 'GMMET001',
+ business_unit_id: 52,
+ offence_title: 'Duplicate offence title A',
+ offence_title_cy: null,
+ date_used_from: '1997-11-16T00:00:00Z',
+ date_used_to: null,
+ offence_oas: 'Offence A',
+ offence_oas_cy: null,
+ },
+ {
+ offence_id: 41800,
+ get_cjs_code: 'GMMET001',
+ business_unit_id: 73,
+ offence_title: 'Duplicate offence title B',
+ offence_title_cy: null,
+ date_used_from: '1998-11-16T00:00:00Z',
+ date_used_to: null,
+ offence_oas: 'Offence B',
+ offence_oas_cy: null,
+ },
+ ],
+};