diff --git a/src/main/assets/locales/cy/respondToClaim/incomeAndExpenditure.json b/src/main/assets/locales/cy/respondToClaim/incomeAndExpenditure.json new file mode 100644 index 000000000..4c329c661 --- /dev/null +++ b/src/main/assets/locales/cy/respondToClaim/incomeAndExpenditure.json @@ -0,0 +1,15 @@ +{ + "caption": "cyResponding to the possession claim", + "pageTitle": "cyIncome and expenses", + "infoParagraph1": "cyDetails about your income and expenses can be used by a judge to reach a decision on your case, and also to decide how much you could afford to repay if you have rent arrears.", + "infoParagraph2": "cyOn the day of the hearing, there may be a duty adviser at the court who can give you advice and represent you for free. They can also use this information to see if there's any additional benefits or support you might be entitled to.", + "infoParagraph3": "cyThe claimant will be able to see your answers.", + "question": "cyDo you want to provide details of your income and expenses?", + "options": { + "yes": "cyYes", + "no": "cyNo" + }, + "errors": { + "provideFinanceDetails": "cySelect if you want to provide details of your income and expenses" + } +} diff --git a/src/main/assets/locales/cy/respondToClaim/regularIncome.json b/src/main/assets/locales/cy/respondToClaim/regularIncome.json new file mode 100644 index 000000000..6bda8e65d --- /dev/null +++ b/src/main/assets/locales/cy/respondToClaim/regularIncome.json @@ -0,0 +1,60 @@ +{ + "caption": "cyRespond to a property possession claim", + "pageTitle": "cyWhat regular income do you receive? (Optional)", + "hintText": "cySelect all that apply. Enter total amount in pounds and pence, for example £148.00 or £148.50. The information you provide must be truthful and accurate.", + "options": { + "incomeFromJobs": "cyIncome from all jobs you do", + "pension": "cyPension – state and private", + "universalCredit": "cyUniversal Credit", + "otherBenefits": "cyOther benefits and credits", + "moneyFromElsewhere": "cyMoney from somewhere else (for example, child maintenance payments or someone in your household gives you money)" + }, + "subFields": { + "amount": "cyTotal amount received", + "frequency": "cyReceived every:", + "moneyFromElsewhereDetailsLabel": "cyGive details", + "moneyFromElsewhereDetailsHint": "cyGive details about the other sources of income and how much you usually receive" + }, + "frequency": { + "week": "cyWeek", + "month": "cyMonth" + }, + "errors": { + "incomeFromJobsAmount": { + "required": "cyEnter the total amount you receive from all jobs you do", + "largeAmount": "cyThe total amount you receive from all jobs you do each week or month must be less than £1 billion" + }, + "incomeFromJobsFrequency": { + "required": "cySelect how frequently you receive income from all jobs you do" + }, + "pensionAmount": { + "required": "cyEnter the total amount you receive from a state or private pension", + "largeAmount": "cyThe total amount you receive from pension (state and private) each week or month must be less than £1 billion" + }, + "pensionFrequency": { + "required": "cySelect how frequently you receive income from a state or private pension" + }, + "universalCreditAmount": { + "required": "cyEnter the total amount you receive from Universal Credit", + "largeAmount": "cyThe total amount you receive from Universal Credit each week or month must be less than £1 billion" + }, + "universalCreditFrequency": { + "required": "cySelect how frequently you receive Universal Credit" + }, + "otherBenefitsAmount": { + "required": "cyEnter the total amount you receive from other benefits and credits", + "largeAmount": "cyThe total amount you receive from other benefits and credit each week or month must be less than £1 billion" + }, + "otherBenefitsFrequency": { + "required": "cySelect how frequently you receive income from other benefits and credits" + }, + "moneyFromElsewhereDetails": { + "required": "cyGive details about the other sources of income and how much you usually receive" + }, + "amount": { + "negative": "cyAmount cannot be negative", + "tooLarge": "cyAmount is too large", + "invalidFormat": "cyEnter amount in pounds and pence, for example 148.00 or 148.50" + } + } +} diff --git a/src/main/assets/locales/en/respondToClaim/incomeAndExpenditure.json b/src/main/assets/locales/en/respondToClaim/incomeAndExpenditure.json new file mode 100644 index 000000000..24a05316b --- /dev/null +++ b/src/main/assets/locales/en/respondToClaim/incomeAndExpenditure.json @@ -0,0 +1,15 @@ +{ + "caption": "Responding to the possession claim", + "pageTitle": "Income and expenses", + "infoParagraph1": "Details about your income and expenses can be used by a judge to reach a decision on your case, and also to decide how much you could afford to repay if you have rent arrears.", + "infoParagraph2": "On the day of the hearing, there may be a duty adviser at the court who can give you advice and represent you for free. They can also use this information to see if there's any additional benefits or support you might be entitled to.", + "infoParagraph3": "The claimant will be able to see your answers.", + "question": "Do you want to provide details of your income and expenses?", + "options": { + "yes": "Yes", + "no": "No" + }, + "errors": { + "provideFinanceDetails": "Select if you want to provide details of your income and expenses" + } +} diff --git a/src/main/assets/locales/en/respondToClaim/regularIncome.json b/src/main/assets/locales/en/respondToClaim/regularIncome.json new file mode 100644 index 000000000..473b8b8a4 --- /dev/null +++ b/src/main/assets/locales/en/respondToClaim/regularIncome.json @@ -0,0 +1,60 @@ +{ + "caption": "Respond to a property possession claim", + "pageTitle": "What regular income do you receive? (Optional)", + "hintText": "Select all that apply. Enter total amount in pounds and pence, for example £148.00 or £148.50. The information you provide must be truthful and accurate.", + "options": { + "incomeFromJobs": "Income from all jobs you do", + "pension": "Pension – state and private", + "universalCredit": "Universal Credit", + "otherBenefits": "Other benefits and credits", + "moneyFromElsewhere": "Money from somewhere else (for example, child maintenance payments or someone in your household gives you money)" + }, + "subFields": { + "amount": "Total amount received", + "frequency": "Received every:", + "moneyFromElsewhereDetailsLabel": "Give details", + "moneyFromElsewhereDetailsHint": "Give details about the other sources of income and how much you usually receive" + }, + "frequency": { + "week": "Week", + "month": "Month" + }, + "errors": { + "incomeFromJobsAmount": { + "required": "Enter the total amount you receive from all jobs you do", + "largeAmount": "The total amount you receive from all jobs you do each week or month must be less than £1 billion" + }, + "incomeFromJobsFrequency": { + "required": "Select how frequently you receive income from all jobs you do" + }, + "pensionAmount": { + "required": "Enter the total amount you receive from a state or private pension", + "largeAmount": "The total amount you receive from pension (state and private) each week or month must be less than £1 billion" + }, + "pensionFrequency": { + "required": "Select how frequently you receive income from a state or private pension" + }, + "universalCreditAmount": { + "required": "Enter the total amount you receive from Universal Credit", + "largeAmount": "The total amount you receive from Universal Credit each week or month must be less than £1 billion" + }, + "universalCreditFrequency": { + "required": "Select how frequently you receive Universal Credit" + }, + "otherBenefitsAmount": { + "required": "Enter the total amount you receive from other benefits and credits", + "largeAmount": "The total amount you receive from other benefits and credit each week or month must be less than £1 billion" + }, + "otherBenefitsFrequency": { + "required": "Select how frequently you receive income from other benefits and credits" + }, + "moneyFromElsewhereDetails": { + "required": "Give details about the other sources of income and how much you usually receive" + }, + "amount": { + "negative": "Amount cannot be negative", + "tooLarge": "Amount is too large", + "invalidFormat": "Enter amount in pounds and pence, for example 148.00 or 148.50" + } + } +} diff --git a/src/main/constants/validation.ts b/src/main/constants/validation.ts new file mode 100644 index 000000000..bcd4d9933 --- /dev/null +++ b/src/main/constants/validation.ts @@ -0,0 +1,10 @@ +/** + * Validation constants for income and expenditure forms. + * Shared across all income/expense related steps. + */ + +/** Maximum income/expense amount: £1 billion in pence */ +export const MAX_INCOME_AMOUNT = 1_000_000_000; + +/** Amount format: up to 10 digits, exactly 2 decimal places */ +export const AMOUNT_FORMAT_REGEX = /^\d{1,10}\.\d{2}$/; diff --git a/src/main/interfaces/ccdCase.interface.ts b/src/main/interfaces/ccdCase.interface.ts index 97ca959de..10cbf9866 100644 --- a/src/main/interfaces/ccdCase.interface.ts +++ b/src/main/interfaces/ccdCase.interface.ts @@ -3,12 +3,15 @@ export enum CaseState { SUBMITTED = 'Submitted', } -export type YesNoValue = 'YES' | 'NO' | null; +export type YesNoValue = 'Yes' | 'No' | null; export type TenancyTypeCorrectValue = YesNoValue | 'NOT_SURE'; export type ContactPreference = 'EMAIL' | 'POST' | null; export type YesNoNotSureValue = 'YES' | 'NO' | 'NOT_SURE'; +export type FrequencyValue = 'WEEKLY' | 'MONTHLY'; +export type PenceAmount = string; + export interface CcdUserCase { id: string; state: CaseState; @@ -37,6 +40,25 @@ export interface Address { Country?: string; } +export interface HouseholdCircumstances { + shareIncomeExpenseDetails?: YesNoValue; + incomeFromJobs?: YesNoValue; + incomeFromJobsAmount?: PenceAmount; + incomeFromJobsFrequency?: FrequencyValue; + pension?: YesNoValue; + pensionAmount?: PenceAmount; + pensionFrequency?: FrequencyValue; + universalCredit?: YesNoValue; + universalCreditAmount?: PenceAmount; + universalCreditFrequency?: FrequencyValue; + ucApplicationDate?: string; + otherBenefits?: YesNoValue; + otherBenefitsAmount?: PenceAmount; + otherBenefitsFrequency?: FrequencyValue; + moneyFromElsewhere?: YesNoValue; + moneyFromElsewhereDetails?: string; +} + export interface PossessionClaimResponse { defendantContactDetails?: { party?: { @@ -61,6 +83,7 @@ export interface PossessionClaimResponse { dateOfBirth?: string; landlordRegistered?: YesNoNotSureValue; landlordLicensed?: YesNoNotSureValue; + householdCircumstances?: HouseholdCircumstances; }; } diff --git a/src/main/interfaces/formFieldConfig.interface.ts b/src/main/interfaces/formFieldConfig.interface.ts index 401e39925..ac9004677 100644 --- a/src/main/interfaces/formFieldConfig.interface.ts +++ b/src/main/interfaces/formFieldConfig.interface.ts @@ -42,6 +42,8 @@ export interface FormFieldConfig { classes?: string; attributes?: Record; legendClasses?: string; + prefix?: { text: string }; + suffix?: { text: string }; // Pre-built component config for Nunjucks template rendering component?: Record; componentType?: ComponentType; diff --git a/src/main/modules/steps/formBuilder/componentBuilders.ts b/src/main/modules/steps/formBuilder/componentBuilders.ts index d046deafb..546f364bc 100644 --- a/src/main/modules/steps/formBuilder/componentBuilders.ts +++ b/src/main/modules/steps/formBuilder/componentBuilders.ts @@ -55,6 +55,12 @@ export function buildComponentConfig( switch (field.type) { case 'text': { component.value = (fieldValue as string) || ''; + if (field.prefix) { + component.prefix = field.prefix; + } + if (field.suffix) { + component.suffix = field.suffix; + } componentType = 'input'; break; } diff --git a/src/main/steps/respond-to-claim/contact-preferences-telephone/index.ts b/src/main/steps/respond-to-claim/contact-preferences-telephone/index.ts index 49d9d5d8e..5541ea38c 100644 --- a/src/main/steps/respond-to-claim/contact-preferences-telephone/index.ts +++ b/src/main/steps/respond-to-claim/contact-preferences-telephone/index.ts @@ -1,6 +1,7 @@ import type { PossessionClaimResponse } from '../../../interfaces/ccdCase.interface'; import type { StepDefinition } from '../../../interfaces/stepFormData.interface'; import { createFormStep } from '../../../modules/steps'; +import { toYesNoEnum } from '../../utils'; import { buildCcdCaseForPossessionClaimResponse as buildAndSubmitPossessionClaimResponse } from '../../utils/populateResponseToClaimPayloadmap'; import { flowConfig } from '../flow.config'; @@ -94,7 +95,7 @@ export const step: StepDefinition = createFormStep({ }, }, defendantResponses: { - contactByPhone: telephoneForm.contactByTelephone === 'yes' ? 'YES' : 'NO', + contactByPhone: toYesNoEnum(telephoneForm.contactByTelephone), }, }; diff --git a/src/main/steps/respond-to-claim/contact-preferences-text-message/index.ts b/src/main/steps/respond-to-claim/contact-preferences-text-message/index.ts index 8a76680b1..3844b8bef 100644 --- a/src/main/steps/respond-to-claim/contact-preferences-text-message/index.ts +++ b/src/main/steps/respond-to-claim/contact-preferences-text-message/index.ts @@ -1,6 +1,7 @@ import type { PossessionClaimResponse } from '../../../interfaces/ccdCase.interface'; import type { StepDefinition } from '../../../interfaces/stepFormData.interface'; import { createFormStep } from '../../../modules/steps'; +import { toYesNoEnum } from '../../utils'; import { buildCcdCaseForPossessionClaimResponse as buildAndSubmitPossessionClaimResponse } from '../../utils/populateResponseToClaimPayloadmap'; import { flowConfig } from '../flow.config'; @@ -47,7 +48,7 @@ export const step: StepDefinition = createFormStep({ const possessionClaimResponse: PossessionClaimResponse = { defendantResponses: { - contactByText: textForm.contactByTextMessage === 'yes' ? 'YES' : 'NO', + contactByText: toYesNoEnum(textForm.contactByTextMessage), }, }; diff --git a/src/main/steps/respond-to-claim/flow.config.ts b/src/main/steps/respond-to-claim/flow.config.ts index 4e0319df3..edbb9de0f 100644 --- a/src/main/steps/respond-to-claim/flow.config.ts +++ b/src/main/steps/respond-to-claim/flow.config.ts @@ -3,11 +3,15 @@ import { type Request } from 'express'; import type { JourneyFlowConfig } from '../../interfaces/stepFlow.interface'; import { getPreviousPageForArrears, + hasSelectedUniversalCredit, isDefendantNameKnown, + isFinanceDetailsProvided, + isFromIncomeAndExpenditure, isNoticeDateProvided, isNoticeServed, isRentArrearsClaim, isTenancyStartDateKnown, + isUniversalCreditSelected, isWelshProperty, } from '../utils'; @@ -50,11 +54,12 @@ export const flowConfig: JourneyFlowConfig = { 'your-circumstances', 'exceptional-hardship', 'income-and-expenditure', - 'what-regular-income-do-you-receive', + 'regular-income', 'have-you-applied-for-universal-credit', 'priority-debts', 'priority-debt-details', 'what-other-regular-expenses-do-you-have', + 'upload-docs', 'end-now', ], steps: { @@ -356,26 +361,70 @@ export const flowConfig: JourneyFlowConfig = { }, 'income-and-expenditure': { previousStep: 'exceptional-hardship', - defaultNext: 'what-regular-income-do-you-receive', + routes: [ + { + condition: async (req: Request): Promise => { + return !(await isFinanceDetailsProvided(req)); + }, + nextStep: 'upload-docs', + }, + { + condition: async (req: Request): Promise => { + return isFinanceDetailsProvided(req); + }, + nextStep: 'regular-income', + }, + ], + defaultNext: 'regular-income', }, - 'what-regular-income-do-you-receive': { + 'regular-income': { previousStep: 'income-and-expenditure', + routes: [ + { + condition: async (req: Request): Promise => { + return isUniversalCreditSelected(req); + }, + nextStep: 'priority-debts', + }, + { + condition: async (req: Request): Promise => { + return !(await isUniversalCreditSelected(req)); + }, + nextStep: 'have-you-applied-for-universal-credit', + }, + ], defaultNext: 'have-you-applied-for-universal-credit', }, 'have-you-applied-for-universal-credit': { - previousStep: 'what-regular-income-do-you-receive', + previousStep: 'regular-income', defaultNext: 'priority-debts', }, 'priority-debts': { - previousStep: 'have-you-applied-for-universal-credit', + previousStep: async (req: Request): Promise => { + const selectedUniversalCredit = await hasSelectedUniversalCredit(req); + return selectedUniversalCredit ? 'regular-income' : 'have-you-applied-for-universal-credit'; + }, defaultNext: 'priority-debt-details', }, 'priority-debt-details': { - previousStep: 'priority-debts', defaultNext: 'what-other-regular-expenses-do-you-have', }, 'what-other-regular-expenses-do-you-have': { - previousStep: 'priority-debt-details', + defaultNext: 'upload-docs', + }, + 'upload-docs': { + previousStep: async (req: Request): Promise => { + const fromIncomeExpenditure = await isFromIncomeAndExpenditure(req); + return fromIncomeExpenditure ? 'income-and-expenditure' : 'what-other-regular-expenses-do-you-have'; + }, + routes: [ + { + condition: async (req: Request): Promise => { + return isFromIncomeAndExpenditure(req); + }, + nextStep: 'income-and-expenditure', + }, + ], defaultNext: 'end-now', }, }, diff --git a/src/main/steps/respond-to-claim/income-and-expenditure/incomeAndExpenditure.njk b/src/main/steps/respond-to-claim/income-and-expenditure/incomeAndExpenditure.njk index 36fc31606..a8ce574f7 100644 --- a/src/main/steps/respond-to-claim/income-and-expenditure/incomeAndExpenditure.njk +++ b/src/main/steps/respond-to-claim/income-and-expenditure/incomeAndExpenditure.njk @@ -1,15 +1,42 @@ {% extends "stepsTemplate.njk" %} {% from "macros/stepButtons.njk" import stepButtons %} {% from "govuk/components/error-summary/macro.njk" import govukErrorSummary %} +{% from "govuk/components/radios/macro.njk" import govukRadios %} {% from "macros/csrf.njk" import csrfProtection %} + +{% block pageTitle %}{{ pageTitle }} – HM Courts & Tribunals Service – GOV.UK{% endblock %} + {% block mainContent %} {% if errorSummary %} {{ govukErrorSummary(errorSummary) }} {% endif %} -

Income and expenses (placeholder)

-

This is a placeholder for Income and Expenditure step.

-
- {{ stepButtons(continue, saveForLater) }} + + {% if caption %} + {{ caption }} + {% endif %} + +

{{ pageTitle }}

+ + {% if infoParagraph1 %} +

{{ infoParagraph1 }}

+ {% endif %} + + {% if infoParagraph2 %} +

{{ infoParagraph2 }}

+ {% endif %} + + {% if infoParagraph3 %} +

{{ infoParagraph3 }}

+ {% endif %} + + + {% for field in fields %} + {% if field.componentType == 'radios' %} + {{ govukRadios(field.component) }} + {% endif %} + {% endfor %} + + {{ stepButtons(saveAndContinue, saveForLater) }} {{ csrfProtection(csrfToken) }}
{% endblock %} diff --git a/src/main/steps/respond-to-claim/income-and-expenditure/index.ts b/src/main/steps/respond-to-claim/income-and-expenditure/index.ts index dc9f3fe99..6088771ba 100644 --- a/src/main/steps/respond-to-claim/income-and-expenditure/index.ts +++ b/src/main/steps/respond-to-claim/income-and-expenditure/index.ts @@ -1,4 +1,7 @@ +import type { PossessionClaimResponse } from '../../../interfaces/ccdCase.interface'; import type { StepDefinition } from '../../../interfaces/stepFormData.interface'; +import { buildCcdCaseForPossessionClaimResponse } from '../../utils/populateResponseToClaimPayloadmap'; +import { fromYesNoEnum, toYesNoEnum } from '../../utils/yesNoEnum'; import { flowConfig } from '../flow.config'; import { createFormStep } from '@modules/steps'; @@ -8,6 +11,52 @@ export const step: StepDefinition = createFormStep({ journeyFolder: 'respondToClaim', stepDir: __dirname, flowConfig, - fields: [], customTemplate: `${__dirname}/incomeAndExpenditure.njk`, + + getInitialFormData: req => { + const caseData = req.res?.locals?.validatedCase?.data; + const existingAnswer = + caseData?.possessionClaimResponse?.defendantResponses?.householdCircumstances?.shareIncomeExpenseDetails; + const formValue = fromYesNoEnum(existingAnswer); + return formValue ? { provideFinanceDetails: formValue } : {}; + }, + + beforeRedirect: async req => { + const provideFinanceDetails = req.body?.provideFinanceDetails as 'yes' | 'no' | undefined; + if (!provideFinanceDetails) { + return; + } + + const possessionClaimResponse: PossessionClaimResponse = { + defendantResponses: { + householdCircumstances: { + shareIncomeExpenseDetails: toYesNoEnum(provideFinanceDetails), + }, + }, + }; + await buildCcdCaseForPossessionClaimResponse(req, possessionClaimResponse); + }, + + translationKeys: { + caption: 'caption', + pageTitle: 'pageTitle', + infoParagraph1: 'infoParagraph1', + infoParagraph2: 'infoParagraph2', + infoParagraph3: 'infoParagraph3', + question: 'question', + }, + + fields: [ + { + name: 'provideFinanceDetails', + type: 'radio', + required: true, + translationKey: { label: 'question' }, + legendClasses: 'govuk-fieldset__legend--m', + options: [ + { value: 'yes', translationKey: 'options.yes' }, + { value: 'no', translationKey: 'options.no' }, + ], + }, + ], }); diff --git a/src/main/steps/respond-to-claim/regular-income/index.ts b/src/main/steps/respond-to-claim/regular-income/index.ts index f0a5879cf..f8b5bdf15 100644 --- a/src/main/steps/respond-to-claim/regular-income/index.ts +++ b/src/main/steps/respond-to-claim/regular-income/index.ts @@ -1,13 +1,397 @@ +import type { Request } from 'express'; + +import { AMOUNT_FORMAT_REGEX, MAX_INCOME_AMOUNT } from '../../../constants/validation'; +import type { PossessionClaimResponse } from '../../../interfaces/ccdCase.interface'; import type { StepDefinition } from '../../../interfaces/stepFormData.interface'; +import { fromYesNoEnum, penceToPounds, poundsToPence, toYesNoEnum } from '../../utils'; +import { buildCcdCaseForPossessionClaimResponse } from '../../utils/populateResponseToClaimPayloadmap'; import { flowConfig } from '../flow.config'; import { createFormStep } from '@modules/steps'; +const createAmountValidator = + (largeAmountErrorKey: string) => + (value: unknown): boolean | string => { + if (typeof value !== 'string') { + return true; + } + + const trimmed = value.trim(); + if (!trimmed) { + return true; + } // Let required validation handle empty values + + const normalized = trimmed.replace(/,/g, ''); + const numericValue = parseFloat(normalized); + + if (!Number.isNaN(numericValue)) { + if (numericValue < 0) { + return 'errors.amount.negative'; + } + // AC: £1bn or more should error + if (numericValue >= MAX_INCOME_AMOUNT) { + return largeAmountErrorKey; + } + } + + if (!AMOUNT_FORMAT_REGEX.test(normalized)) { + return 'errors.amount.invalidFormat'; + } + + return true; + }; + +const validateIncomeFromJobsAmount = createAmountValidator('errors.incomeFromJobsAmount.largeAmount'); +const validatePensionAmount = createAmountValidator('errors.pensionAmount.largeAmount'); +const validateUniversalCreditAmount = createAmountValidator('errors.universalCreditAmount.largeAmount'); +const validateOtherBenefitsAmount = createAmountValidator('errors.otherBenefitsAmount.largeAmount'); + export const step: StepDefinition = createFormStep({ - stepName: 'what-regular-income-do-you-receive', + stepName: 'regular-income', journeyFolder: 'respondToClaim', stepDir: __dirname, flowConfig, - fields: [], - customTemplate: `${__dirname}/regularIncome.njk`, + showCancelButton: false, + + getInitialFormData: (req: Request) => { + const caseData = req.res?.locals?.validatedCase?.data; + const hc = caseData?.possessionClaimResponse?.defendantResponses?.householdCircumstances; + + if (!hc) { + return {}; + } + + const formData: Record = {}; + const selectedIncome: string[] = []; + + // Income from jobs + if (fromYesNoEnum(hc.incomeFromJobs) === 'yes') { + selectedIncome.push('incomeFromJobs'); + if (hc.incomeFromJobsAmount) { + formData['regularIncome.incomeFromJobsAmount'] = penceToPounds(hc.incomeFromJobsAmount as string); + } + if (hc.incomeFromJobsFrequency) { + formData['regularIncome.incomeFromJobsFrequency'] = hc.incomeFromJobsFrequency; + } + } + + // Pension + if (fromYesNoEnum(hc.pension) === 'yes') { + selectedIncome.push('pension'); + if (hc.pensionAmount) { + formData['regularIncome.pensionAmount'] = penceToPounds(hc.pensionAmount as string); + } + if (hc.pensionFrequency) { + formData['regularIncome.pensionFrequency'] = hc.pensionFrequency; + } + } + + // Universal Credit + if (fromYesNoEnum(hc.universalCredit) === 'yes') { + selectedIncome.push('universalCredit'); + if (hc.universalCreditAmount) { + formData['regularIncome.universalCreditAmount'] = penceToPounds(hc.universalCreditAmount as string); + } + if (hc.universalCreditFrequency) { + formData['regularIncome.universalCreditFrequency'] = hc.universalCreditFrequency; + } + } + + // Other benefits + if (fromYesNoEnum(hc.otherBenefits) === 'yes') { + selectedIncome.push('otherBenefits'); + if (hc.otherBenefitsAmount) { + formData['regularIncome.otherBenefitsAmount'] = penceToPounds(hc.otherBenefitsAmount as string); + } + if (hc.otherBenefitsFrequency) { + formData['regularIncome.otherBenefitsFrequency'] = hc.otherBenefitsFrequency; + } + } + + // Money from elsewhere + if (fromYesNoEnum(hc.moneyFromElsewhere) === 'yes') { + selectedIncome.push('moneyFromElsewhere'); + if (hc.moneyFromElsewhereDetails) { + formData['regularIncome.moneyFromElsewhereDetails'] = hc.moneyFromElsewhereDetails; + } + } + + if (selectedIncome.length > 0) { + formData.regularIncome = selectedIncome; + } + + return formData; + }, + + beforeRedirect: async (req: Request) => { + const selectedIncome = req.body?.regularIncome as string | string[] | undefined; + + const incomeArray = Array.isArray(selectedIncome) ? selectedIncome : selectedIncome ? [selectedIncome] : []; + const householdCircumstances: Record = {}; + + // Income from jobs + householdCircumstances.incomeFromJobs = toYesNoEnum(incomeArray.includes('incomeFromJobs') ? 'yes' : 'no'); + if (incomeArray.includes('incomeFromJobs')) { + const amountRaw = req.body?.['regularIncome.incomeFromJobsAmount'] as string | undefined; + const frequency = req.body?.['regularIncome.incomeFromJobsFrequency'] as string | undefined; + + if (amountRaw) { + householdCircumstances.incomeFromJobsAmount = poundsToPence(amountRaw); + } + if (frequency) { + householdCircumstances.incomeFromJobsFrequency = frequency; + } + } + + // Pension + householdCircumstances.pension = toYesNoEnum(incomeArray.includes('pension') ? 'yes' : 'no'); + if (incomeArray.includes('pension')) { + const amountRaw = req.body?.['regularIncome.pensionAmount'] as string | undefined; + const frequency = req.body?.['regularIncome.pensionFrequency'] as string | undefined; + + if (amountRaw) { + householdCircumstances.pensionAmount = poundsToPence(amountRaw); + } + if (frequency) { + householdCircumstances.pensionFrequency = frequency; + } + } + + // Universal Credit + householdCircumstances.universalCredit = toYesNoEnum(incomeArray.includes('universalCredit') ? 'yes' : 'no'); + if (incomeArray.includes('universalCredit')) { + const amountRaw = req.body?.['regularIncome.universalCreditAmount'] as string | undefined; + const frequency = req.body?.['regularIncome.universalCreditFrequency'] as string | undefined; + + if (amountRaw) { + householdCircumstances.universalCreditAmount = poundsToPence(amountRaw); + } + if (frequency) { + householdCircumstances.universalCreditFrequency = frequency; + } + } + + // Other benefits + householdCircumstances.otherBenefits = toYesNoEnum(incomeArray.includes('otherBenefits') ? 'yes' : 'no'); + if (incomeArray.includes('otherBenefits')) { + const amountRaw = req.body?.['regularIncome.otherBenefitsAmount'] as string | undefined; + const frequency = req.body?.['regularIncome.otherBenefitsFrequency'] as string | undefined; + + if (amountRaw) { + householdCircumstances.otherBenefitsAmount = poundsToPence(amountRaw); + } + if (frequency) { + householdCircumstances.otherBenefitsFrequency = frequency; + } + } + + // Money from elsewhere + householdCircumstances.moneyFromElsewhere = toYesNoEnum(incomeArray.includes('moneyFromElsewhere') ? 'yes' : 'no'); + if (incomeArray.includes('moneyFromElsewhere')) { + const details = req.body?.['regularIncome.moneyFromElsewhereDetails'] as string | undefined; + if (details) { + householdCircumstances.moneyFromElsewhereDetails = details; + } + } + + const possessionClaimResponse: PossessionClaimResponse = { + defendantResponses: { + householdCircumstances, + }, + }; + await buildCcdCaseForPossessionClaimResponse(req, possessionClaimResponse); + }, + + translationKeys: { + caption: 'caption', + heading: 'pageTitle', + pageTitle: 'pageTitle', + hintText: 'hintText', + }, + + fields: [ + { + name: 'regularIncome', + type: 'checkbox', + required: false, // Page is optional - can select zero checkboxes + legendClasses: 'govuk-visually-hidden', + translationKey: { + label: 'pageTitle', + hint: 'hintText', + }, + options: [ + // Option 1: Income from all jobs you do + { + value: 'incomeFromJobs', + translationKey: 'options.incomeFromJobs', + subFields: { + incomeFromJobsAmount: { + name: 'incomeFromJobsAmount', + type: 'text', + required: true, + errorMessage: 'errors.incomeFromJobsAmount.required', + translationKey: { + label: 'subFields.amount', + }, + prefix: { + text: '£', + }, + classes: 'govuk-input--width-10', + attributes: { + inputmode: 'decimal', + spellcheck: false, + }, + validator: validateIncomeFromJobsAmount, + }, + incomeFromJobsFrequency: { + name: 'incomeFromJobsFrequency', + type: 'radio', + required: true, + errorMessage: 'errors.incomeFromJobsFrequency.required', + translationKey: { + label: 'subFields.frequency', + }, + options: [ + { value: 'WEEKLY', translationKey: 'frequency.week' }, + { value: 'MONTHLY', translationKey: 'frequency.month' }, + ], + }, + }, + }, + // Option 2: Pension - state and private + { + value: 'pension', + translationKey: 'options.pension', + subFields: { + pensionAmount: { + name: 'pensionAmount', + type: 'text', + required: true, + errorMessage: 'errors.pensionAmount.required', + translationKey: { + label: 'subFields.amount', + }, + prefix: { + text: '£', + }, + classes: 'govuk-input--width-10', + attributes: { + inputmode: 'decimal', + spellcheck: false, + }, + validator: validatePensionAmount, + }, + pensionFrequency: { + name: 'pensionFrequency', + type: 'radio', + required: true, + errorMessage: 'errors.pensionFrequency.required', + translationKey: { + label: 'subFields.frequency', + }, + options: [ + { value: 'WEEKLY', translationKey: 'frequency.week' }, + { value: 'MONTHLY', translationKey: 'frequency.month' }, + ], + }, + }, + }, + // Option 3: Universal Credit + { + value: 'universalCredit', + translationKey: 'options.universalCredit', + subFields: { + universalCreditAmount: { + name: 'universalCreditAmount', + type: 'text', + required: true, + errorMessage: 'errors.universalCreditAmount.required', + translationKey: { + label: 'subFields.amount', + }, + prefix: { + text: '£', + }, + classes: 'govuk-input--width-10', + attributes: { + inputmode: 'decimal', + spellcheck: false, + }, + validator: validateUniversalCreditAmount, + }, + universalCreditFrequency: { + name: 'universalCreditFrequency', + type: 'radio', + required: true, + errorMessage: 'errors.universalCreditFrequency.required', + translationKey: { + label: 'subFields.frequency', + }, + options: [ + { value: 'WEEKLY', translationKey: 'frequency.week' }, + { value: 'MONTHLY', translationKey: 'frequency.month' }, + ], + }, + }, + }, + // Option 4: Other benefits and credits + { + value: 'otherBenefits', + translationKey: 'options.otherBenefits', + subFields: { + otherBenefitsAmount: { + name: 'otherBenefitsAmount', + type: 'text', + required: true, + errorMessage: 'errors.otherBenefitsAmount.required', + translationKey: { + label: 'subFields.amount', + }, + prefix: { + text: '£', + }, + classes: 'govuk-input--width-10', + attributes: { + inputmode: 'decimal', + spellcheck: false, + }, + validator: validateOtherBenefitsAmount, + }, + otherBenefitsFrequency: { + name: 'otherBenefitsFrequency', + type: 'radio', + required: true, + errorMessage: 'errors.otherBenefitsFrequency.required', + translationKey: { + label: 'subFields.frequency', + }, + options: [ + { value: 'WEEKLY', translationKey: 'frequency.week' }, + { value: 'MONTHLY', translationKey: 'frequency.month' }, + ], + }, + }, + }, + // Option 5: Money from somewhere else - TEXTAREA ONLY (no amount/frequency) + { + value: 'moneyFromElsewhere', + translationKey: 'options.moneyFromElsewhere', + subFields: { + moneyFromElsewhereDetails: { + name: 'moneyFromElsewhereDetails', + type: 'character-count', + maxLength: 500, + required: true, + errorMessage: 'errors.moneyFromElsewhereDetails.required', + labelClasses: 'govuk-visually-hidden', + translationKey: { + label: 'subFields.moneyFromElsewhereDetailsLabel', + hint: 'subFields.moneyFromElsewhereDetailsHint', + }, + }, + }, + }, + ], + }, + ], }); diff --git a/src/main/steps/respond-to-claim/stepRegistry.ts b/src/main/steps/respond-to-claim/stepRegistry.ts index ba3951d96..a66eeed9d 100644 --- a/src/main/steps/respond-to-claim/stepRegistry.ts +++ b/src/main/steps/respond-to-claim/stepRegistry.ts @@ -26,8 +26,8 @@ import { step as doAnyOtherAdultsLiveInYourHome } from './other-adults'; import { step as paymentInterstitial } from './payment-interstitial'; import { step as priorityDebtDetails } from './priority-debt-details'; import { step as priorityDebts } from './priority-debts'; -import { step as whatOtherRegularExpensesDoYouHave } from './regular-expenses'; -import { step as whatRegularIncomeDoYouReceive } from './regular-income'; +import { step as regularExpenses } from './regular-expenses'; +import { step as regularIncome } from './regular-income'; import { step as rentArrearsDispute } from './rent-arrears-dispute'; import { step as repaymentsAgreed } from './repayments-agreed'; import { step as repaymentsMade } from './repayments-made'; @@ -37,6 +37,7 @@ import { step as tenancyDateDetails } from './tenancy-date-details'; import { step as tenancyDateUnknown } from './tenancy-date-unknown'; import { step as tenancyTypeDetails } from './tenancy-type-details'; import { step as haveYouAppliedForUniversalCredit } from './universal-credit'; +import { step as uploadDocs } from './upload-docs'; import { step as writtenTerms } from './written-terms'; export const stepRegistry: Record = { @@ -74,9 +75,10 @@ export const stepRegistry: Record = { 'your-circumstances': yourCircumstances, 'exceptional-hardship': exceptionalHardship, 'income-and-expenditure': incomeAndExpenditure, - 'what-regular-income-do-you-receive': whatRegularIncomeDoYouReceive, + 'regular-income': regularIncome, 'have-you-applied-for-universal-credit': haveYouAppliedForUniversalCredit, 'priority-debts': priorityDebts, 'priority-debt-details': priorityDebtDetails, - 'what-other-regular-expenses-do-you-have': whatOtherRegularExpensesDoYouHave, + 'what-other-regular-expenses-do-you-have': regularExpenses, + 'upload-docs': uploadDocs, }; diff --git a/src/main/steps/respond-to-claim/tenancy-type-details/index.ts b/src/main/steps/respond-to-claim/tenancy-type-details/index.ts index 81021c933..300acf227 100644 --- a/src/main/steps/respond-to-claim/tenancy-type-details/index.ts +++ b/src/main/steps/respond-to-claim/tenancy-type-details/index.ts @@ -53,14 +53,14 @@ const fieldsConfig: FormFieldConfig[] = [ const STEP_NAME = 'tenancy-type-details'; const TENANCY_TYPE_CONFIRM_TO_CCD: Record = { - yes: 'YES', - no: 'NO', + yes: 'Yes', + no: 'No', notSure: 'NOT_SURE', }; const CCD_TO_TENANCY_TYPE_CONFIRM: Record, string> = { - YES: 'yes', - NO: 'no', + Yes: 'yes', + No: 'no', NOT_SURE: 'notSure', }; @@ -111,7 +111,7 @@ export const step: StepDefinition = createFormStep({ } const initial: Record = { tenancyTypeConfirm: formValue }; - if (existingTenancyTypeCorrect === 'NO' && existingCorrectedTenancyType) { + if (existingTenancyTypeCorrect === 'No' && existingCorrectedTenancyType) { initial['tenancyTypeConfirm.correctType'] = existingCorrectedTenancyType; } return initial; diff --git a/src/main/steps/respond-to-claim/upload-docs/index.ts b/src/main/steps/respond-to-claim/upload-docs/index.ts new file mode 100644 index 000000000..3c513ccc0 --- /dev/null +++ b/src/main/steps/respond-to-claim/upload-docs/index.ts @@ -0,0 +1,13 @@ +import type { StepDefinition } from '../../../interfaces/stepFormData.interface'; +import { flowConfig } from '../flow.config'; + +import { createFormStep } from '@modules/steps'; + +export const step: StepDefinition = createFormStep({ + stepName: 'upload-docs', + journeyFolder: 'respondToClaim', + stepDir: __dirname, + flowConfig, + fields: [], + customTemplate: `${__dirname}/uploadDocs.njk`, +}); diff --git a/src/main/steps/respond-to-claim/regular-income/regularIncome.njk b/src/main/steps/respond-to-claim/upload-docs/uploadDocs.njk similarity index 72% rename from src/main/steps/respond-to-claim/regular-income/regularIncome.njk rename to src/main/steps/respond-to-claim/upload-docs/uploadDocs.njk index 70473eab5..7ab5f3151 100644 --- a/src/main/steps/respond-to-claim/regular-income/regularIncome.njk +++ b/src/main/steps/respond-to-claim/upload-docs/uploadDocs.njk @@ -6,8 +6,8 @@ {% if errorSummary %} {{ govukErrorSummary(errorSummary) }} {% endif %} -

What regular income do you receive? (placeholder)

-

This is a placeholder for What Regular Income Do You Receive step.

+

Upload documents (placeholder)

+

This is a placeholder for Upload Documents step.

{{ stepButtons(continue, saveForLater) }} {{ csrfProtection(csrfToken) }} diff --git a/src/main/steps/utils/currencyConversion.ts b/src/main/steps/utils/currencyConversion.ts new file mode 100644 index 000000000..0959b81d8 --- /dev/null +++ b/src/main/steps/utils/currencyConversion.ts @@ -0,0 +1,56 @@ +/** + * Utility functions for converting between pounds (frontend) and pence (backend). + * Backend stores currency amounts as integers in pence to avoid floating point issues. + */ + +/** + * Converts pence (backend) to pounds (frontend display) + * @param pence - Amount in pence as string or number + * @returns Amount in pounds with 2 decimal places, or undefined if invalid + * @example + * penceToPounds('14850') // returns '148.50' + * penceToPounds(14850) // returns '148.50' + * penceToPounds(null) // returns undefined + */ +export function penceToPounds(pence: string | number | undefined): string | undefined { + if (pence === undefined || pence === null || pence === '') { + return undefined; + } + + const penceValue = typeof pence === 'string' ? parseFloat(pence) : pence; + + if (Number.isNaN(penceValue)) { + return undefined; + } + + const pounds = penceValue / 100; + return pounds.toFixed(2); +} + +/** + * Converts pounds (frontend) to pence (backend storage) + * Handles comma-separated input and rounds to nearest pence + * @param pounds - Amount in pounds as string (may contain commas) + * @returns Amount in pence as string, or undefined if invalid + * @example + * poundsToPence('148.50') // returns '14850' + * poundsToPence('1,234.56') // returns '123456' + * poundsToPence('invalid') // returns undefined + */ +export function poundsToPence(pounds: string | undefined): string | undefined { + if (!pounds) { + return undefined; + } + + // Remove commas from formatted input + const normalized = pounds.replace(/,/g, ''); + const poundsValue = parseFloat(normalized); + + if (Number.isNaN(poundsValue)) { + return undefined; + } + + // Round to nearest pence (avoids floating point issues) + const pence = Math.round(poundsValue * 100); + return String(pence); +} diff --git a/src/main/steps/utils/hasSelectedUniversalCredit.ts b/src/main/steps/utils/hasSelectedUniversalCredit.ts new file mode 100644 index 000000000..8141c0377 --- /dev/null +++ b/src/main/steps/utils/hasSelectedUniversalCredit.ts @@ -0,0 +1,15 @@ +import type { Request } from 'express'; + +/** + * Checks if the user selected Universal Credit in regular-income step. + * + * Checks if the universalCredit field exists in CCD case data. + * Returns true if universalCredit was selected, false otherwise. + */ +export const hasSelectedUniversalCredit = async (req: Request): Promise => { + const caseData = req.res?.locals?.validatedCase?.data; + const universalCredit = + caseData?.possessionClaimResponse?.defendantResponses?.householdCircumstances?.universalCredit; + + return universalCredit !== undefined; +}; diff --git a/src/main/steps/utils/index.ts b/src/main/steps/utils/index.ts index b9842c158..6b786c1e8 100644 --- a/src/main/steps/utils/index.ts +++ b/src/main/steps/utils/index.ts @@ -4,5 +4,11 @@ export { isNoticeDateProvided } from './isNoticeDateProvided'; export { isRentArrearsClaim } from './isRentArrearsClaim'; export { isNoticeServed } from './isNoticeServed'; export { isTenancyStartDateKnown } from './isTenancyStartDateKnown'; +export { isFromIncomeAndExpenditure } from './isFromIncomeAndExpenditure'; +export { isFinanceDetailsProvided } from './isFinanceDetailsProvided'; +export { isUniversalCreditSelected } from './isUniversalCreditSelected'; +export { hasSelectedUniversalCredit } from './hasSelectedUniversalCredit'; export { getPreviousPageForArrears } from './journeyHelpers'; export { formatDatePartsToISODate } from './dateUtils'; +export { toYesNoEnum, fromYesNoEnum } from './yesNoEnum'; +export { penceToPounds, poundsToPence } from './currencyConversion'; diff --git a/src/main/steps/utils/isFinanceDetailsProvided.ts b/src/main/steps/utils/isFinanceDetailsProvided.ts new file mode 100644 index 000000000..60db45e51 --- /dev/null +++ b/src/main/steps/utils/isFinanceDetailsProvided.ts @@ -0,0 +1,13 @@ +import type { Request } from 'express'; + +/** + * Checks if the user agreed to provide finance details (income and expenses). + * + * Checks the current form submission (req.body) for the provideFinanceDetails field. + * Returns true if user selected 'yes', false otherwise. + */ +export const isFinanceDetailsProvided = async (req: Request): Promise => { + const provideFinanceDetails = req.body?.provideFinanceDetails; + + return provideFinanceDetails === 'yes'; +}; diff --git a/src/main/steps/utils/isFromIncomeAndExpenditure.ts b/src/main/steps/utils/isFromIncomeAndExpenditure.ts new file mode 100644 index 000000000..ff9411854 --- /dev/null +++ b/src/main/steps/utils/isFromIncomeAndExpenditure.ts @@ -0,0 +1,16 @@ +import type { Request } from 'express'; + +/** + * Checks if the journey came from the income-and-expenditure step. + * + * Checks if the shareIncomeExpenseDetails field exists in CCD case data. + * This field is only set when user completes the income-and-expenditure step. + * Returns true if field exists, indicating journey came from income-and-expenditure. + */ +export const isFromIncomeAndExpenditure = async (req: Request): Promise => { + const caseData = req.res?.locals?.validatedCase?.data; + const shareIncomeExpenseDetails = + caseData?.possessionClaimResponse?.defendantResponses?.householdCircumstances?.shareIncomeExpenseDetails; + + return shareIncomeExpenseDetails !== undefined; +}; diff --git a/src/main/steps/utils/isUniversalCreditSelected.ts b/src/main/steps/utils/isUniversalCreditSelected.ts new file mode 100644 index 000000000..0920912c9 --- /dev/null +++ b/src/main/steps/utils/isUniversalCreditSelected.ts @@ -0,0 +1,13 @@ +import type { Request } from 'express'; + +/** + * Checks if the user selected Universal Credit in their regular income sources. + * + * Checks the current form submission (req.body) for the regularIncome field. + * Returns true if 'universalCredit' is included in the selected income sources, false otherwise. + */ +export const isUniversalCreditSelected = async (req: Request): Promise => { + const regularIncome = req.body?.regularIncome; + + return [regularIncome].flat().filter(Boolean).includes('universalCredit'); +}; diff --git a/src/main/steps/utils/yesNoEnum.ts b/src/main/steps/utils/yesNoEnum.ts new file mode 100644 index 000000000..bed5a2121 --- /dev/null +++ b/src/main/steps/utils/yesNoEnum.ts @@ -0,0 +1,33 @@ +/** + * Utility functions for converting between frontend yes/no values and backend Yes/No enums. + * Used for CCD API integration where boolean choices are represented as enum strings. + */ + +import type { YesNoValue } from '../../interfaces/ccdCase.interface'; + +/** + * Converts frontend 'yes'/'no' to backend 'Yes'/'No' enum. + * @example toYesNoEnum('yes') // returns 'Yes' + */ +export function toYesNoEnum(value: 'yes' | 'no'): YesNoValue { + return value.toLowerCase() === 'yes' ? 'Yes' : 'No'; +} + +/** + * Converts backend 'Yes'/'No' enum to frontend 'yes'/'no'. + * Case-insensitive for backward compatibility. + * @example fromYesNoEnum('Yes') // returns 'yes' + */ +export function fromYesNoEnum(value: YesNoValue | string | undefined): 'yes' | 'no' | undefined { + if (!value) { + return undefined; + } + const normalizedValue = value.charAt(0).toUpperCase() + value.slice(1).toLowerCase(); + if (normalizedValue === 'Yes') { + return 'yes'; + } + if (normalizedValue === 'No') { + return 'no'; + } + return undefined; +} diff --git a/src/main/views/components/subFields.njk b/src/main/views/components/subFields.njk index 1a5947866..b54ecb539 100644 --- a/src/main/views/components/subFields.njk +++ b/src/main/views/components/subFields.njk @@ -1,6 +1,8 @@ {% from "govuk/components/input/macro.njk" import govukInput %} {% from "govuk/components/textarea/macro.njk" import govukTextarea %} {% from "govuk/components/character-count/macro.njk" import govukCharacterCount %} +{% from "govuk/components/radios/macro.njk" import govukRadios %} +{% from "govuk/components/checkboxes/macro.njk" import govukCheckboxes %} {% from "govuk/components/date-input/macro.njk" import govukDateInput %} @@ -12,6 +14,10 @@ {{ govukTextarea(subField.component) }} {% elif subField.componentType == 'characterCount' %} {{ govukCharacterCount(subField.component) }} + {% elif subField.componentType == 'radios' %} + {{ govukRadios(subField.component) }} + {% elif subField.componentType == 'checkboxes' %} + {{ govukCheckboxes(subField.component) }} {% elif subField.componentType == 'dateInput' %} {{ govukDateInput(subField.component) }} {% elif subField.componentType == 'postcodeLookup' %} diff --git a/src/test/unit/modules/steps/formBuilder/fieldTranslation.test.ts b/src/test/unit/modules/steps/formBuilder/fieldTranslation.test.ts index b090c181d..24cd56d49 100644 --- a/src/test/unit/modules/steps/formBuilder/fieldTranslation.test.ts +++ b/src/test/unit/modules/steps/formBuilder/fieldTranslation.test.ts @@ -73,4 +73,32 @@ describe('translateFields', () => { expect(month.value).toBe(''); expect(year.value).toBe(''); }); + + it('passes prefix and suffix into input component config', () => { + const amountFields: FormFieldConfig[] = [ + { + name: 'amount', + type: 'text', + translationKey: { label: 'amountLabel' }, + prefix: { text: '£' }, + suffix: { text: 'per month' }, + }, + ]; + + const result = translateFields( + amountFields, + mockT as unknown as TFunction, + { amount: '10.00' }, + {}, + false, + '', + {}, + mockNunjucksEnv + ); + const field = result[0] as FormFieldConfig; + const component = field.component as { prefix?: { text: string }; suffix?: { text: string } } | undefined; + + expect(component?.prefix).toEqual({ text: '£' }); + expect(component?.suffix).toEqual({ text: 'per month' }); + }); }); diff --git a/src/test/unit/steps/respond-to-claim/tenancy-type-details.test.ts b/src/test/unit/steps/respond-to-claim/tenancy-type-details.test.ts index 39fd5ec87..ed1064ea1 100644 --- a/src/test/unit/steps/respond-to-claim/tenancy-type-details.test.ts +++ b/src/test/unit/steps/respond-to-claim/tenancy-type-details.test.ts @@ -70,16 +70,16 @@ describe('respond-to-claim tenancy-type-details step', () => { }); it.each([ - ['YES', 'yes'], - ['NO', 'no'], + ['Yes', 'yes'], + ['No', 'no'], ['NOT_SURE', 'notSure'], ])('returns tenancyTypeConfirm=%s when CCD has %s', (ccdValue, formValue) => { const result = testedStep.getInitialFormData(makeReq(ccdValue)); expect(result).toMatchObject({ tenancyTypeConfirm: formValue }); }); - it('also returns correctType when CCD value is NO and tenancyType is set', () => { - const result = testedStep.getInitialFormData(makeReq('NO', 'Assured shorthold')); + it('also returns correctType when CCD value is No and tenancyType is set', () => { + const result = testedStep.getInitialFormData(makeReq('No', 'Assured shorthold')); expect(result).toEqual({ tenancyTypeConfirm: 'no', 'tenancyTypeConfirm.correctType': 'Assured shorthold', @@ -99,8 +99,8 @@ describe('respond-to-claim tenancy-type-details step', () => { describe('beforeRedirect', () => { it.each([ - ['yes', 'YES'], - ['no', 'NO'], + ['yes', 'Yes'], + ['no', 'No'], ['notSure', 'NOT_SURE'], ['maybe', undefined], [undefined, undefined], diff --git a/src/test/unit/steps/utils/currencyConversion.test.ts b/src/test/unit/steps/utils/currencyConversion.test.ts new file mode 100644 index 000000000..2ae278d92 --- /dev/null +++ b/src/test/unit/steps/utils/currencyConversion.test.ts @@ -0,0 +1,130 @@ +import { penceToPounds, poundsToPence } from '../../../../main/steps/utils/currencyConversion'; + +describe('currencyConversion', () => { + describe('penceToPounds', () => { + it('should convert pence string to pounds with 2 decimal places', () => { + expect(penceToPounds('14850')).toBe('148.50'); + expect(penceToPounds('100')).toBe('1.00'); + expect(penceToPounds('50')).toBe('0.50'); + expect(penceToPounds('5')).toBe('0.05'); + expect(penceToPounds('0')).toBe('0.00'); + }); + + it('should convert pence number to pounds with 2 decimal places', () => { + expect(penceToPounds(14850)).toBe('148.50'); + expect(penceToPounds(100)).toBe('1.00'); + expect(penceToPounds(50)).toBe('0.50'); + expect(penceToPounds(5)).toBe('0.05'); + expect(penceToPounds(0)).toBe('0.00'); + }); + + it('should handle large amounts', () => { + expect(penceToPounds('100000000')).toBe('1000000.00'); // £1 million + expect(penceToPounds(100000000)).toBe('1000000.00'); + }); + + it('should return undefined for null', () => { + expect(penceToPounds(null as unknown as string)).toBeUndefined(); + }); + + it('should return undefined for undefined', () => { + expect(penceToPounds(undefined)).toBeUndefined(); + }); + + it('should return undefined for empty string', () => { + expect(penceToPounds('')).toBeUndefined(); + }); + + it('should return undefined for invalid string', () => { + expect(penceToPounds('invalid')).toBeUndefined(); + expect(penceToPounds('abc123')).toBeUndefined(); + expect(penceToPounds('£100')).toBeUndefined(); + }); + + it('should handle negative values', () => { + expect(penceToPounds('-14850')).toBe('-148.50'); + expect(penceToPounds(-14850)).toBe('-148.50'); + }); + + it('should handle decimal pence values', () => { + expect(penceToPounds('14850.50')).toBe('148.50'); // 148.505 with toFixed(2) + expect(penceToPounds(14850.5)).toBe('148.50'); + }); + }); + + describe('poundsToPence', () => { + it('should convert pounds string to pence', () => { + expect(poundsToPence('148.50')).toBe('14850'); + expect(poundsToPence('1.00')).toBe('100'); + expect(poundsToPence('0.50')).toBe('50'); + expect(poundsToPence('0.05')).toBe('5'); + expect(poundsToPence('0.00')).toBe('0'); + }); + + it('should handle comma-separated values', () => { + expect(poundsToPence('1,234.56')).toBe('123456'); + expect(poundsToPence('10,000.00')).toBe('1000000'); + expect(poundsToPence('1,000,000.00')).toBe('100000000'); + }); + + it('should round to nearest pence (avoids floating point issues)', () => { + expect(poundsToPence('148.505')).toBe('14851'); // Rounds up + expect(poundsToPence('148.504')).toBe('14850'); // Rounds down + expect(poundsToPence('0.555')).toBe('56'); // Rounds up + }); + + it('should handle whole numbers (no decimal)', () => { + expect(poundsToPence('148')).toBe('14800'); + expect(poundsToPence('1')).toBe('100'); + expect(poundsToPence('0')).toBe('0'); + }); + + it('should handle single decimal place', () => { + expect(poundsToPence('148.5')).toBe('14850'); + expect(poundsToPence('1.5')).toBe('150'); + }); + + it('should return undefined for undefined', () => { + expect(poundsToPence(undefined)).toBeUndefined(); + }); + + it('should return undefined for empty string', () => { + expect(poundsToPence('')).toBeUndefined(); + }); + + it('should return undefined for invalid string', () => { + expect(poundsToPence('invalid')).toBeUndefined(); + expect(poundsToPence('abc123')).toBeUndefined(); + expect(poundsToPence('£100')).toBeUndefined(); + }); + + it('should handle negative values', () => { + expect(poundsToPence('-148.50')).toBe('-14850'); + expect(poundsToPence('-1.00')).toBe('-100'); + }); + + it('should handle whitespace by normalizing', () => { + expect(poundsToPence(' 148.50 ')).toBe('14850'); + expect(poundsToPence(' 1.00 ')).toBe('100'); + }); + }); + + describe('penceToPounds and poundsToPence round-trip', () => { + it('should maintain value integrity in round-trip conversion', () => { + const testValues = ['14850', '100', '50', '0', '123456']; + + testValues.forEach(pence => { + const pounds = penceToPounds(pence); + const backToPence = poundsToPence(pounds as string); + expect(backToPence).toBe(pence); + }); + }); + + it('should handle round-trip with comma-formatted input', () => { + const poundsWithCommas = '1,234.56'; + const pence = poundsToPence(poundsWithCommas); + const backToPounds = penceToPounds(pence as string); + expect(backToPounds).toBe('1234.56'); // Commas removed but value preserved + }); + }); +});