diff --git a/app/controllers/SuspensionPeriodRangeDateController.scala b/app/controllers/SuspensionPeriodRangeDateController.scala index 7778d6cf..ddcf192e 100644 --- a/app/controllers/SuspensionPeriodRangeDateController.scala +++ b/app/controllers/SuspensionPeriodRangeDateController.scala @@ -33,7 +33,6 @@ import services.NationalDirectDebitService import uk.gov.hmrc.play.bootstrap.frontend.controller.FrontendBaseController import utils.MaskAndFormatUtils.formatAmount import views.html.SuspensionPeriodRangeDateView -import play.api.i18n.Lang.logger import scala.concurrent.{ExecutionContext, Future} @@ -54,24 +53,31 @@ class SuspensionPeriodRangeDateController @Inject() ( with Logging { def onPageLoad(mode: Mode): Action[AnyContent] = - (identify andThen getData andThen requireData) { implicit request => + (identify andThen getData andThen requireData).async { implicit request => val userAnswers = request.userAnswers if (nddsService.suspendPaymentPlanGuard(userAnswers)) { - val form = formProvider() - val preparedForm = request.userAnswers.get(SuspensionPeriodRangeDatePage) match { - case Some(value) => form.fill(value) - case None => form - } + val planDates = userAnswers.get(PaymentPlanDetailsQuery).map(_.paymentPlanDetails) + val planStart = planDates.flatMap(_.scheduledPaymentStartDate) + val planEnd = planDates.flatMap(_.scheduledPaymentEndDate) + + nddsService.earliestSuspendStartDate().map { earliestStartDate => + val form = formProvider(planStart, planEnd, earliestStartDate) + + val preparedForm = userAnswers.get(SuspensionPeriodRangeDatePage) match { + case Some(value) => form.fill(value) + case None => form + } - val (planReference, paymentAmount) = extractPlanData - Ok(view(preparedForm, mode, planReference, paymentAmount)) + val (planReference, paymentAmount) = extractPlanData + Ok(view(preparedForm, mode, planReference, paymentAmount)) + } } else { val planType = request.userAnswers.get(ManagePaymentPlanTypePage).getOrElse("") logger.error( s"NDDS Payment Plan Guard: Cannot carry out suspension functionality for this plan type: $planType" ) - Redirect(routes.JourneyRecoveryController.onPageLoad()) + Future.successful(Redirect(routes.JourneyRecoveryController.onPageLoad())) } } @@ -84,17 +90,23 @@ class SuspensionPeriodRangeDateController @Inject() ( Future.successful(Redirect(routes.JourneyRecoveryController.onPageLoad())) } else { val (planReference, paymentAmount) = extractPlanData - val form = formProvider() - form - .bindFromRequest() - .fold( - formWithErrors => Future.successful(BadRequest(view(formWithErrors, mode, planReference, paymentAmount))), - value => - for { - updatedAnswers <- Future.fromTry(request.userAnswers.set(SuspensionPeriodRangeDatePage, value)) - _ <- sessionRepository.set(updatedAnswers) - } yield Redirect(navigator.nextPage(SuspensionPeriodRangeDatePage, mode, updatedAnswers)) - ) + val planStart = planDetail.scheduledPaymentStartDate + val planEnd = planDetail.scheduledPaymentEndDate + + nddsService.earliestSuspendStartDate().flatMap { earliestStartDate => + val form = formProvider(planStart, planEnd, earliestStartDate) + + form + .bindFromRequest() + .fold( + formWithErrors => Future.successful(BadRequest(view(formWithErrors, mode, planReference, paymentAmount))), + value => + for { + updatedAnswers <- Future.fromTry(request.userAnswers.set(SuspensionPeriodRangeDatePage, value)) + _ <- sessionRepository.set(updatedAnswers) + } yield Redirect(navigator.nextPage(SuspensionPeriodRangeDatePage, mode, updatedAnswers)) + ) + } } case None => diff --git a/app/forms/SuspensionPeriodRangeDateFormProvider.scala b/app/forms/SuspensionPeriodRangeDateFormProvider.scala index 8377f388..4a045a44 100644 --- a/app/forms/SuspensionPeriodRangeDateFormProvider.scala +++ b/app/forms/SuspensionPeriodRangeDateFormProvider.scala @@ -20,34 +20,121 @@ import forms.mappings.Mappings import models.SuspensionPeriodRange import play.api.data.Form import play.api.data.Forms.mapping +import play.api.data.validation.{Constraint, Invalid, Valid} import play.api.i18n.Messages import utils.DateFormats - +import java.time.LocalDate +import java.time.format.DateTimeFormatter import javax.inject.Inject class SuspensionPeriodRangeDateFormProvider @Inject() extends Mappings { - def apply()(implicit messages: Messages): Form[SuspensionPeriodRange] = + private val MaxMonthsAhead = 6 + private val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("d MMMM yyyy") + + def apply( + planStartDateOpt: Option[LocalDate], + planEndDateOpt: Option[LocalDate], + earliestStartDate: LocalDate // 3 working days from today + )(implicit messages: Messages): Form[SuspensionPeriodRange] = { + Form( mapping( "suspensionPeriodRangeStartDate" -> customPaymentDate( - invalidKey = "suspensionPeriodRangeStartDate.error.invalid", + invalidKey = "suspensionPeriodRangeDate.error.invalid.startDate.base", allRequiredKey = "suspensionPeriodRangeStartDate.error.required.all", twoRequiredKey = "suspensionPeriodRangeStartDate.error.required.two", requiredKey = "suspensionPeriodRangeStartDate.error.required", dateFormats = DateFormats.defaultDateFormats ), "suspensionPeriodRangeEndDate" -> customPaymentDate( - invalidKey = "suspensionPeriodRangeEndDate.error.invalid", + invalidKey = "suspensionPeriodRangeDate.error.invalid.endDate.base", allRequiredKey = "suspensionPeriodRangeEndDate.error.required.all", twoRequiredKey = "suspensionPeriodRangeEndDate.error.required.two", requiredKey = "suspensionPeriodRangeEndDate.error.required", dateFormats = DateFormats.defaultDateFormats ) )(SuspensionPeriodRange.apply)(range => Some((range.startDate, range.endDate))) - .verifying( - "suspensionPeriodRangeDate.error.endBeforeStart", - range => !range.endDate.isBefore(range.startDate) - ) + .verifying(startDateConstraint(planStartDateOpt, planEndDateOpt, earliestStartDate)) + .verifying(endDateConstraint(planStartDateOpt, planEndDateOpt)) ) + } + + private def isSuspendStartDateValid( + startDate: LocalDate, + planStartDateOpt: Option[LocalDate], + planEndDateOpt: Option[LocalDate], + earliestStartDate: LocalDate + ): Boolean = { + val lowerBound = planStartDateOpt.fold(earliestStartDate)(psd => if (psd.isAfter(earliestStartDate)) psd else earliestStartDate) + val upperBound = planEndDateOpt.fold(LocalDate.now().plusMonths(MaxMonthsAhead)) { ped => + val sixMonthsFromToday = LocalDate.now().plusMonths(MaxMonthsAhead) + if (ped.isBefore(sixMonthsFromToday)) ped else sixMonthsFromToday + } + !startDate.isBefore(lowerBound) && !startDate.isAfter(upperBound) + } + + private def isSuspendEndDateValid( + endDate: LocalDate, + startDate: LocalDate, + planStartDateOpt: Option[LocalDate], + planEndDateOpt: Option[LocalDate] + ): Boolean = { + val lowerBound = planStartDateOpt.fold(startDate)(psd => if (psd.isAfter(startDate)) psd else startDate) + val upperBound = planEndDateOpt.fold(LocalDate.now().plusMonths(MaxMonthsAhead)) { ped => + val sixMonthsFromToday = LocalDate.now().plusMonths(MaxMonthsAhead) + if (ped.isBefore(sixMonthsFromToday)) ped else sixMonthsFromToday + } + !endDate.isBefore(lowerBound) && !endDate.isAfter(upperBound) + } + + private def startDateConstraint( + planStartDateOpt: Option[LocalDate], + planEndDateOpt: Option[LocalDate], + earliestStartDate: LocalDate + )(implicit messages: Messages): Constraint[SuspensionPeriodRange] = + Constraint[SuspensionPeriodRange]("suspensionPeriodRangeDate.error.startDate") { range => + val lowerBound = planStartDateOpt.fold(earliestStartDate)(psd => if (psd.isAfter(earliestStartDate)) psd else earliestStartDate) + val upperBound = planEndDateOpt.fold(LocalDate.now().plusMonths(MaxMonthsAhead)) { ped => + val sixMonthsFromToday = LocalDate.now().plusMonths(MaxMonthsAhead) + if (ped.isBefore(sixMonthsFromToday)) ped else sixMonthsFromToday + } + if (isSuspendStartDateValid(range.startDate, planStartDateOpt, planEndDateOpt, earliestStartDate)) Valid + else Invalid(messages("suspensionPeriodRangeDate.error.startDate", lowerBound.format(dateFormatter), upperBound.format(dateFormatter))) + } + + private def endDateConstraint( + planStartDateOpt: Option[LocalDate], + planEndDateOpt: Option[LocalDate] + )(implicit messages: Messages): Constraint[SuspensionPeriodRange] = + Constraint[SuspensionPeriodRange]("suspensionPeriodRangeDate.error.endDate") { range => + + val startValid = isSuspendStartDateValid( + range.startDate, + planStartDateOpt, + planEndDateOpt, + LocalDate.now().plusDays(3) + ) + + if (!startValid) { + Valid + } else { + + val lowerBound = planStartDateOpt.fold(range.startDate)(psd => if (psd.isAfter(range.startDate)) psd else range.startDate) + val upperBound = planEndDateOpt.fold(LocalDate.now().plusMonths(MaxMonthsAhead)) { ped => + val sixMonthsFromToday = LocalDate.now().plusMonths(MaxMonthsAhead) + if (ped.isBefore(sixMonthsFromToday)) ped else sixMonthsFromToday + } + + if ( + !range.endDate.isBefore(range.startDate) && + isSuspendEndDateValid(range.endDate, range.startDate, planStartDateOpt, planEndDateOpt) + ) { + Valid + } else { + Invalid(messages("suspensionPeriodRangeDate.error.endDate", lowerBound.format(dateFormatter), upperBound.format(dateFormatter))) + } + } + } + } diff --git a/app/services/NationalDirectDebitService.scala b/app/services/NationalDirectDebitService.scala index 65179dde..f0dc3e62 100644 --- a/app/services/NationalDirectDebitService.scala +++ b/app/services/NationalDirectDebitService.scala @@ -386,6 +386,38 @@ class NationalDirectDebitService @Inject() (nddConnector: NationalDirectDebitCon } } + import java.time.LocalDate + + private val MaxMonthsAhead = 6 + + def isSuspendStartDateValid( + startDate: LocalDate, + planStartDateOpt: Option[LocalDate], + planEndDateOpt: Option[LocalDate], + earliestStartDate: LocalDate + ): Boolean = { + val latestStartDate = LocalDate.now().plusMonths(MaxMonthsAhead) + + val afterPlanStart = planStartDateOpt.forall(planStart => !startDate.isBefore(planStart)) + val beforePlanEnd = planEndDateOpt.forall(planEnd => !startDate.isAfter(planEnd)) + val afterEarliest = !startDate.isBefore(earliestStartDate) + val beforeLatest = !startDate.isAfter(latestStartDate) + + afterPlanStart && beforePlanEnd && afterEarliest && beforeLatest + } + + def isSuspendEndDateValid( + endDate: LocalDate, + startDate: LocalDate, + planEndDateOpt: Option[LocalDate] + ): Boolean = { + val within6Months = !endDate.isAfter(startDate.plusMonths(MaxMonthsAhead)) + val afterStart = !endDate.isBefore(startDate) + val beforePlanEnd = planEndDateOpt.forall(planEnd => !endDate.isAfter(planEnd)) + + within6Months && afterStart && beforePlanEnd + } + def isPaymentPlanCancellable(userAnswers: UserAnswers): Boolean = { userAnswers .get(ManagePaymentPlanTypePage) @@ -398,6 +430,15 @@ class NationalDirectDebitService @Inject() (nddConnector: NationalDirectDebitCon ) } + def earliestSuspendStartDate(workingDaysOffset: Int = 3)(implicit hc: HeaderCarrier): Future[LocalDate] = { + val today = LocalDate.now() + val request = WorkingDaysOffsetRequest(baseDate = today.toString, offsetWorkingDays = workingDaysOffset) + + nddConnector.getFutureWorkingDays(request).map { response => + LocalDate.parse(response.date) + } + } + def suspendPaymentPlanGuard(userAnswers: UserAnswers): Boolean = isBudgetPaymentPlan(userAnswers) diff --git a/conf/messages.en b/conf/messages.en index ad27c976..303f6d9b 100644 --- a/conf/messages.en +++ b/conf/messages.en @@ -564,6 +564,12 @@ suspensionPeriodRangeEndDate.error.required.two = Enter a valid date. suspensionPeriodRangeDate.error.endBeforeStart = The end date you have entered is not valid. It cannot be before start date. suspensionPeriodRangeDate.to = to suspensionPeriodRangeDate.checkYourAnswersLabel = Suspension period +suspensionPeriodRangeDate.error.invalid.startDate.base = Enter a real start date. +suspensionPeriodRangeDate.error.invalid.endDate.base = Enter a real end date. +suspensionPeriodRangeDate.error.startDate = Suspension period start date must be between {0} and {1} +suspensionPeriodRangeDate.error.endDate = Suspension period end date must be between {0} and {1} + + checkYourSuspensionDetails.title = Check your suspension details checkYourSuspensionDetails.heading = Check your suspension details diff --git a/test/controllers/SuspensionPeriodRangeDateControllerSpec.scala b/test/controllers/SuspensionPeriodRangeDateControllerSpec.scala index a5f3874e..d1148113 100644 --- a/test/controllers/SuspensionPeriodRangeDateControllerSpec.scala +++ b/test/controllers/SuspensionPeriodRangeDateControllerSpec.scala @@ -43,7 +43,12 @@ class SuspensionPeriodRangeDateControllerSpec extends SpecBase with MockitoSugar implicit private val messages: Messages = stubMessages() private val formProvider = new SuspensionPeriodRangeDateFormProvider() - private def form = formProvider() + + private val PlanStartDate = Some(LocalDate.of(2025, 10, 1)) + private val planEndDate = Some(LocalDate.of(2025, 10, 10)) + private val earliestStartDateMock = LocalDate.now().plusDays(3) + + private def form = formProvider(PlanStartDate, planEndDate, earliestStartDateMock) private val onwardRoute = Call("GET", "/foo") @@ -128,7 +133,10 @@ class SuspensionPeriodRangeDateControllerSpec extends SpecBase with MockitoSugar "must return OK and the correct view for GET with BudgetPaymentPlan" in { val mockNddsService = mock[NationalDirectDebitService] + when(mockNddsService.suspendPaymentPlanGuard(any())).thenReturn(true) + when(mockNddsService.earliestSuspendStartDate(any())(any())) + .thenReturn(Future.successful(LocalDate.now().plusDays(3))) val application = applicationBuilder(userAnswers = Some(userAnswersWithBudgetPlan)) .overrides(bind[NationalDirectDebitService].toInstance(mockNddsService)) @@ -166,6 +174,8 @@ class SuspensionPeriodRangeDateControllerSpec extends SpecBase with MockitoSugar "must populate the view correctly on GET when previously answered" in { val mockNddsService = mock[NationalDirectDebitService] when(mockNddsService.suspendPaymentPlanGuard(any())).thenReturn(true) + when(mockNddsService.earliestSuspendStartDate(any())(any())) + .thenReturn(Future.successful(LocalDate.now().plusDays(3))) val userAnswers = userAnswersWithBudgetPlan .set(SuspensionPeriodRangeDatePage, validAnswer) .success @@ -191,18 +201,37 @@ class SuspensionPeriodRangeDateControllerSpec extends SpecBase with MockitoSugar "must redirect to next page when valid POST with BudgetPaymentPlan" in { val mockSessionRepository = mock[SessionRepository] + val mockNddsService = mock[NationalDirectDebitService] + when(mockSessionRepository.set(any())) thenReturn Future.successful(true) + when(mockNddsService.suspendPaymentPlanGuard(any())).thenReturn(true) + when(mockNddsService.earliestSuspendStartDate(any())(any())) + .thenReturn(Future.successful(LocalDate.now().plusDays(3))) val application = applicationBuilder(userAnswers = Some(userAnswersWithBudgetPlan)) .overrides( bind[Navigator].toInstance(new FakeNavigator(onwardRoute)), - bind[SessionRepository].toInstance(mockSessionRepository) + bind[SessionRepository].toInstance(mockSessionRepository), + bind[NationalDirectDebitService].toInstance(mockNddsService) ) .build() + val startDate = LocalDate.now().plusDays(4) + val endDate = LocalDate.now().plusDays(10) + + val validPostRequest = FakeRequest(POST, suspensionPeriodRangeDateRoute) + .withFormUrlEncodedBody( + "suspensionPeriodRangeStartDate.day" -> startDate.getDayOfMonth.toString, + "suspensionPeriodRangeStartDate.month" -> startDate.getMonthValue.toString, + "suspensionPeriodRangeStartDate.year" -> startDate.getYear.toString, + "suspensionPeriodRangeEndDate.day" -> endDate.getDayOfMonth.toString, + "suspensionPeriodRangeEndDate.month" -> endDate.getMonthValue.toString, + "suspensionPeriodRangeEndDate.year" -> endDate.getYear.toString + ) + running(application) { - val result = route(application, postRequest()).value + val result = route(application, validPostRequest).value status(result) mustEqual SEE_OTHER redirectLocation(result).value mustEqual onwardRoute.url } @@ -210,13 +239,17 @@ class SuspensionPeriodRangeDateControllerSpec extends SpecBase with MockitoSugar "must redirect to Journey Recovery for POST with non-Budget plan" in { val mockSessionRepository = mock[SessionRepository] + val mockNddsService = mock[NationalDirectDebitService] when(mockSessionRepository.set(any())) thenReturn Future.successful(true) + when(mockNddsService.earliestSuspendStartDate(any())(any())) + .thenReturn(Future.successful(LocalDate.now().plusDays(3))) val application = applicationBuilder(userAnswers = Some(userAnswersWithSinglePlan)) .overrides( bind[Navigator].toInstance(new FakeNavigator(onwardRoute)), - bind[SessionRepository].toInstance(mockSessionRepository) + bind[SessionRepository].toInstance(mockSessionRepository), + bind[NationalDirectDebitService].toInstance(mockNddsService) ) .build() @@ -228,7 +261,19 @@ class SuspensionPeriodRangeDateControllerSpec extends SpecBase with MockitoSugar } "must return BadRequest and errors when invalid POST data submitted" in { - val application = applicationBuilder(userAnswers = Some(userAnswersWithBudgetPlan)).build() + val mockSessionRepository = mock[SessionRepository] + val mockNddsService = mock[NationalDirectDebitService] + when(mockSessionRepository.set(any())) thenReturn Future.successful(true) + when(mockNddsService.earliestSuspendStartDate(any())(any())) + .thenReturn(Future.successful(LocalDate.now().plusDays(3))) + + val application = applicationBuilder(userAnswers = Some(userAnswersWithBudgetPlan)) + .overrides( + bind[Navigator].toInstance(new FakeNavigator(onwardRoute)), + bind[SessionRepository].toInstance(mockSessionRepository), + bind[NationalDirectDebitService].toInstance(mockNddsService) + ) + .build() val request = FakeRequest(POST, suspensionPeriodRangeDateRoute) diff --git a/test/forms/SuspensionPeriodRangeDateFormProviderSpec.scala b/test/forms/SuspensionPeriodRangeDateFormProviderSpec.scala index 26faa052..68c2a45c 100644 --- a/test/forms/SuspensionPeriodRangeDateFormProviderSpec.scala +++ b/test/forms/SuspensionPeriodRangeDateFormProviderSpec.scala @@ -16,23 +16,33 @@ package forms -import forms.behaviours.DateBehaviours import models.SuspensionPeriodRange +import org.scalatest.OptionValues.convertOptionToValuable +import org.scalatest.matchers.must.Matchers +import org.scalatest.wordspec.AnyWordSpec import play.api.i18n.Messages import play.api.test.Helpers.stubMessages + import java.time.LocalDate -class SuspensionPeriodRangeDateFormProviderSpec extends DateBehaviours { +class SuspensionPeriodRangeDateFormProviderSpec extends AnyWordSpec with Matchers { + + implicit val messages: Messages = stubMessages() + + private val today = LocalDate.of(2025, 11, 1) + private val earliestStart = today.plusDays(3) + private val planStart = LocalDate.of(2025, 11, 10) + private val planEnd = LocalDate.of(2026, 1, 31) - private implicit val messages: Messages = stubMessages() - private val form = new SuspensionPeriodRangeDateFormProvider()() + private val formProvider = new SuspensionPeriodRangeDateFormProvider() + private val form = formProvider(Some(planStart), Some(planEnd), earliestStart) - private val validStartDate = LocalDate.of(2025, 10, 1) - private val validEndDate = LocalDate.of(2025, 10, 10) + private val validStartDate = planStart.plusDays(2) + private val validEndDate = validStartDate.plusDays(10) - "SuspensionPeriodRangeDateFormProvider" - { + "SuspensionPeriodRangeDateFormProvider" should { - "must bind valid data correctly" in { + "bind valid dates correctly" in { val data = Map( "suspensionPeriodRangeStartDate.day" -> validStartDate.getDayOfMonth.toString, "suspensionPeriodRangeStartDate.month" -> validStartDate.getMonthValue.toString, @@ -47,82 +57,115 @@ class SuspensionPeriodRangeDateFormProviderSpec extends DateBehaviours { result.value.value mustBe SuspensionPeriodRange(validStartDate, validEndDate) } - "must return an error when end date is before start date" in { + "fail when start date is before plan start date" in { + val invalidStart = planStart.minusDays(1) + val data = Map( - "suspensionPeriodRangeStartDate.day" -> "10", - "suspensionPeriodRangeStartDate.month" -> "10", - "suspensionPeriodRangeStartDate.year" -> "2025", - "suspensionPeriodRangeEndDate.day" -> "05", - "suspensionPeriodRangeEndDate.month" -> "10", - "suspensionPeriodRangeEndDate.year" -> "2025" + "suspensionPeriodRangeStartDate.day" -> invalidStart.getDayOfMonth.toString, + "suspensionPeriodRangeStartDate.month" -> invalidStart.getMonthValue.toString, + "suspensionPeriodRangeStartDate.year" -> invalidStart.getYear.toString, + "suspensionPeriodRangeEndDate.day" -> validEndDate.getDayOfMonth.toString, + "suspensionPeriodRangeEndDate.month" -> validEndDate.getMonthValue.toString, + "suspensionPeriodRangeEndDate.year" -> validEndDate.getYear.toString ) val result = form.bind(data) - result.errors.map(_.message) must contain("suspensionPeriodRangeDate.error.endBeforeStart") + result.errors.map(_.message) must contain("suspensionPeriodRangeDate.error.startDate") } - "must return generic date errors when start date is missing" in { + "fail when start date is before earliest allowed start date" in { + val invalidStart = earliestStart.minusDays(1) + val data = Map( - "suspensionPeriodRangeEndDate.day" -> "10", - "suspensionPeriodRangeEndDate.month" -> "10", - "suspensionPeriodRangeEndDate.year" -> "2025" + "suspensionPeriodRangeStartDate.day" -> invalidStart.getDayOfMonth.toString, + "suspensionPeriodRangeStartDate.month" -> invalidStart.getMonthValue.toString, + "suspensionPeriodRangeStartDate.year" -> invalidStart.getYear.toString, + "suspensionPeriodRangeEndDate.day" -> validEndDate.getDayOfMonth.toString, + "suspensionPeriodRangeEndDate.month" -> validEndDate.getMonthValue.toString, + "suspensionPeriodRangeEndDate.year" -> validEndDate.getYear.toString ) val result = form.bind(data) - result.errors.map(_.message) must contain allElementsOf Seq( - "date.error.day", - "date.error.month", - "date.error.year" + result.errors.map(_.message) must contain("suspensionPeriodRangeDate.error.startDate") + } + + "fail when start date is after plan end date" in { + val invalidStart = planEnd.plusDays(1) + + val data = Map( + "suspensionPeriodRangeStartDate.day" -> invalidStart.getDayOfMonth.toString, + "suspensionPeriodRangeStartDate.month" -> invalidStart.getMonthValue.toString, + "suspensionPeriodRangeStartDate.year" -> invalidStart.getYear.toString, + "suspensionPeriodRangeEndDate.day" -> validEndDate.getDayOfMonth.toString, + "suspensionPeriodRangeEndDate.month" -> validEndDate.getMonthValue.toString, + "suspensionPeriodRangeEndDate.year" -> validEndDate.getYear.toString ) + + val result = form.bind(data) + result.errors.map(_.message) must contain("suspensionPeriodRangeDate.error.startDate") } - "must return generic date errors when end date is missing" in { + "fail when end date is before start date" in { + val invalidEnd = validStartDate.minusDays(1) + val data = Map( - "suspensionPeriodRangeStartDate.day" -> "10", - "suspensionPeriodRangeStartDate.month" -> "10", - "suspensionPeriodRangeStartDate.year" -> "2025" + "suspensionPeriodRangeStartDate.day" -> validStartDate.getDayOfMonth.toString, + "suspensionPeriodRangeStartDate.month" -> validStartDate.getMonthValue.toString, + "suspensionPeriodRangeStartDate.year" -> validStartDate.getYear.toString, + "suspensionPeriodRangeEndDate.day" -> invalidEnd.getDayOfMonth.toString, + "suspensionPeriodRangeEndDate.month" -> invalidEnd.getMonthValue.toString, + "suspensionPeriodRangeEndDate.year" -> invalidEnd.getYear.toString ) val result = form.bind(data) - result.errors.map(_.message) must contain allElementsOf Seq( - "date.error.day", - "date.error.month", - "date.error.year" + result.errors.map(_.message) must contain("suspensionPeriodRangeDate.error.endDate") + } + + "fail when end date is after plan end date" in { + val invalidEnd = planEnd.plusDays(2) + + val data = Map( + "suspensionPeriodRangeStartDate.day" -> validStartDate.getDayOfMonth.toString, + "suspensionPeriodRangeStartDate.month" -> validStartDate.getMonthValue.toString, + "suspensionPeriodRangeStartDate.year" -> validStartDate.getYear.toString, + "suspensionPeriodRangeEndDate.day" -> invalidEnd.getDayOfMonth.toString, + "suspensionPeriodRangeEndDate.month" -> invalidEnd.getMonthValue.toString, + "suspensionPeriodRangeEndDate.year" -> invalidEnd.getYear.toString ) + + val result = form.bind(data) + result.errors.map(_.message) must contain("suspensionPeriodRangeDate.error.endDate") } - "must return generic date errors when start date is invalid" in { + "should fail when start date format is invalid" in { val data = Map( - "suspensionPeriodRangeStartDate.day" -> "32", + "suspensionPeriodRangeStartDate.day" -> "xx", "suspensionPeriodRangeStartDate.month" -> "13", - "suspensionPeriodRangeStartDate.year" -> "2025", + "suspensionPeriodRangeStartDate.year" -> "abcd", "suspensionPeriodRangeEndDate.day" -> "10", - "suspensionPeriodRangeEndDate.month" -> "10", + "suspensionPeriodRangeEndDate.month" -> "12", "suspensionPeriodRangeEndDate.year" -> "2025" ) val result = form.bind(data) - result.errors.map(_.message) must contain allElementsOf Seq( - "date.error.day", - "date.error.month" - ) + val errors = result.errors.map(_.message) + errors must contain atLeastOneOf ("date.error.day", "date.error.month") } - "must return generic date errors when end date is invalid" in { + "should fail when end date format is invalid" in { val data = Map( - "suspensionPeriodRangeStartDate.day" -> "01", - "suspensionPeriodRangeStartDate.month" -> "10", + "suspensionPeriodRangeStartDate.day" -> "10", + "suspensionPeriodRangeStartDate.month" -> "11", "suspensionPeriodRangeStartDate.year" -> "2025", - "suspensionPeriodRangeEndDate.day" -> "99", - "suspensionPeriodRangeEndDate.month" -> "99", - "suspensionPeriodRangeEndDate.year" -> "2025" + "suspensionPeriodRangeEndDate.day" -> "xx", + "suspensionPeriodRangeEndDate.month" -> "13", + "suspensionPeriodRangeEndDate.year" -> "abcd" ) val result = form.bind(data) - result.errors.map(_.message) must contain allElementsOf Seq( - "date.error.day", - "date.error.month" - ) + val errors = result.errors.map(_.message) + errors must contain atLeastOneOf ("date.error.day", "date.error.month") } + } } diff --git a/test/services/NationalDirectDebitServiceSpec.scala b/test/services/NationalDirectDebitServiceSpec.scala index a4aa6944..f5dccd03 100644 --- a/test/services/NationalDirectDebitServiceSpec.scala +++ b/test/services/NationalDirectDebitServiceSpec.scala @@ -852,6 +852,39 @@ class NationalDirectDebitServiceSpec extends SpecBase with MockitoSugar with Dir } } + "earliestSuspendStartDate" - { + "must successfully return a date that is 3 working days ahead of today" in { + val today = LocalDate.now() + val expectedDate = today.plusDays(5) + when(mockConnector.getFutureWorkingDays(any())(any())) + .thenReturn(Future.successful(EarliestPaymentDate(expectedDate.toString))) + + val result = service.earliestSuspendStartDate()(hc).futureValue + + result mustBe expectedDate + } + + "must successfully return a date that is N working days ahead of today when offset is provided" in { + val today = LocalDate.now() + val expectedDate = today.plusDays(10) + + when(mockConnector.getFutureWorkingDays(any())(any())) + .thenReturn(Future.successful(EarliestPaymentDate(expectedDate.toString))) + + val result = service.earliestSuspendStartDate(workingDaysOffset = 7)(hc).futureValue + + result mustBe expectedDate + } + + "must fail when connector call fails" in { + when(mockConnector.getFutureWorkingDays(any())(any())) + .thenReturn(Future.failed(new Exception("service unavailable"))) + + val exception = intercept[Exception](service.earliestSuspendStartDate()(hc).futureValue) + exception.getMessage must include("service unavailable") + } + } + "suspendPaymentPlanGuard" - { "must return false if single payment for suspend journey" in {