Skip to content
54 changes: 33 additions & 21 deletions app/controllers/SuspensionPeriodRangeDateController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand All @@ -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()))
}
}

Expand All @@ -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 =>
Expand Down
103 changes: 95 additions & 8 deletions app/forms/SuspensionPeriodRangeDateFormProvider.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
}
}
}

}
41 changes: 41 additions & 0 deletions app/services/NationalDirectDebitService.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)

Expand Down
6 changes: 6 additions & 0 deletions conf/messages.en
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 50 additions & 5 deletions test/controllers/SuspensionPeriodRangeDateControllerSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand All @@ -191,32 +201,55 @@ 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
}
}

"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()

Expand All @@ -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)
Expand Down
Loading