From 514f9c2d524a061db61c98655a55bc016f3b7078 Mon Sep 17 00:00:00 2001 From: HG-Accenture <183600681+HG-Accenture@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:41:26 +0000 Subject: [PATCH 01/27] Lock service --- app/config/FrontendAppConfig.scala | 9 ++ .../AccountDetailsNotVerified.scala | 54 +++++++++ .../UkBankAccountDetailsController.scala | 33 ++++-- .../ThirdAttemptLockoutController.scala | 42 +++++++ app/models/Lock.scala | 31 +++++ app/repositories/UserLockRepository.scala | 108 ++++++++++++++++++ app/services/LockService.scala | 62 ++++++++++ .../AccountDetailsNotVerifiedView.scala.html | 57 +++++++++ .../errors/ThirdAttemptLockoutPage.scala.html | 49 ++++++++ conf/messages | 11 ++ .../AccountDetailsNotVerifiedSpec.scala | 87 ++++++++++++++ .../ThirdAttemptLockoutControllerSpec.scala | 53 +++++++++ test/services/LockServiceSpec.scala | 102 +++++++++++++++++ .../HasCompanyBankAccountViewSpec.scala | 16 +-- 14 files changed, 695 insertions(+), 19 deletions(-) create mode 100644 app/controllers/bankdetails/AccountDetailsNotVerified.scala create mode 100644 app/controllers/errors/ThirdAttemptLockoutController.scala create mode 100644 app/models/Lock.scala create mode 100644 app/repositories/UserLockRepository.scala create mode 100644 app/services/LockService.scala create mode 100644 app/views/bankdetails/AccountDetailsNotVerifiedView.scala.html create mode 100644 app/views/errors/ThirdAttemptLockoutPage.scala.html create mode 100644 test/controllers/bankdetails/AccountDetailsNotVerifiedSpec.scala create mode 100644 test/controllers/errors/ThirdAttemptLockoutControllerSpec.scala create mode 100644 test/services/LockServiceSpec.scala diff --git a/app/config/FrontendAppConfig.scala b/app/config/FrontendAppConfig.scala index b916881fe..ace395093 100644 --- a/app/config/FrontendAppConfig.scala +++ b/app/config/FrontendAppConfig.scala @@ -45,6 +45,13 @@ class FrontendAppConfig @Inject()(val servicesConfig: ServicesConfig, runModeCon lazy val eligibilityQuestionUrl: String = loadConfig("microservice.services.vat-registration-eligibility-frontend.question") implicit val appConfig: FrontendAppConfig = this + lazy val ttlLockSeconds:Int = 86400 + lazy val knownFactsLockAttemptLimit:Int = 3 + lazy val isKnownFactsCheckEnabled:Boolean = true + + + + private lazy val thresholdString: String = runModeConfiguration.get[ConfigList]("vat-threshold").render(ConfigRenderOptions.concise()) lazy val thresholds: Seq[VatThreshold] = Json.parse(thresholdString).as[List[VatThreshold]] @@ -288,6 +295,8 @@ class FrontendAppConfig @Inject()(val servicesConfig: ServicesConfig, runModeCon lazy val govukHowToRegister: String = "https://www.gov.uk/register-for-vat/how-register-for-vat" + lazy val vatTaskList: String = s"$host/register-for-vat/application-progress" + lazy val govukTogcVatNotice: String = "https://www.gov.uk/guidance/transfer-a-business-as-a-going-concern-and-vat-notice-7009" lazy val businessDescriptionMaxLength: Int = servicesConfig.getInt("constants.businessDescriptionMaxLength") diff --git a/app/controllers/bankdetails/AccountDetailsNotVerified.scala b/app/controllers/bankdetails/AccountDetailsNotVerified.scala new file mode 100644 index 000000000..6a6884afb --- /dev/null +++ b/app/controllers/bankdetails/AccountDetailsNotVerified.scala @@ -0,0 +1,54 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers.bankdetails + +import config.{AuthClientConnector, BaseControllerComponents, FrontendAppConfig} +import controllers.BaseController +import play.api.data.Form +import play.api.data.Forms.{boolean, single} +import play.api.mvc.{Action, AnyContent} +import services.{LockService, SessionService} +import views.html.bankdetails.AccountDetailsNotVerifiedView + +import javax.inject.Inject +import scala.concurrent.ExecutionContext + +class AccountDetailsNotVerified @Inject()(val authConnector: AuthClientConnector, + val sessionService: SessionService, + lockService: LockService, + view: AccountDetailsNotVerifiedView) + (implicit appConfig: FrontendAppConfig, + val executionContext: ExecutionContext, + baseControllerComponents: BaseControllerComponents) extends BaseController { + + private val AttemptForm: Form[Boolean] = Form(single("value" -> boolean)) + + def show: Action[AnyContent] = isAuthenticatedWithProfile { + implicit request => implicit profile => + lockService.getBarsAttemptsUsed(profile.registrationId).map { attemptsUsed => + if (attemptsUsed >= appConfig.knownFactsLockAttemptLimit) { + Redirect(controllers.errors.routes.ThirdAttemptLockoutController.show) + } else { + val formWithAttempts = AttemptForm.bind(Map( + "value" -> "true", + "attempts" -> attemptsUsed.toString + )) + Ok(view(formWithAttempts)) + } + } + } +} diff --git a/app/controllers/bankdetails/UkBankAccountDetailsController.scala b/app/controllers/bankdetails/UkBankAccountDetailsController.scala index 4a7b2c48e..ca56fb7d4 100644 --- a/app/controllers/bankdetails/UkBankAccountDetailsController.scala +++ b/app/controllers/bankdetails/UkBankAccountDetailsController.scala @@ -18,10 +18,9 @@ package controllers.bankdetails import config.{AuthClientConnector, BaseControllerComponents, FrontendAppConfig} import controllers.BaseController -import forms.EnterBankAccountDetailsForm import forms.EnterBankAccountDetailsForm.{form => enterBankAccountDetailsForm} import play.api.mvc.{Action, AnyContent} -import services.{BankAccountDetailsService, SessionService} +import services.{BankAccountDetailsService, LockService, SessionService} import views.html.bankdetails.EnterCompanyBankAccountDetails import javax.inject.Inject @@ -30,6 +29,7 @@ import scala.concurrent.{ExecutionContext, Future} class UkBankAccountDetailsController @Inject()(val authConnector: AuthClientConnector, val bankAccountDetailsService: BankAccountDetailsService, val sessionService: SessionService, + val lockService: LockService, view: EnterCompanyBankAccountDetails) (implicit appConfig: FrontendAppConfig, val executionContext: ExecutionContext, @@ -38,10 +38,15 @@ class UkBankAccountDetailsController @Inject()(val authConnector: AuthClientConn def show: Action[AnyContent] = isAuthenticatedWithProfile { implicit request => implicit profile => - for { - bankDetails <- bankAccountDetailsService.fetchBankAccountDetails - filledForm = bankDetails.flatMap(_.details).fold(enterBankAccountDetailsForm)(enterBankAccountDetailsForm.fill) - } yield Ok(view(filledForm)) + lockService.getBarsAttemptsUsed(profile.registrationId).map(_ >= appConfig.knownFactsLockAttemptLimit).flatMap { + case true => + Future.successful(Redirect(controllers.errors.routes.ThirdAttemptLockoutController.show)) + case false => + for { + bankDetails <- bankAccountDetailsService.fetchBankAccountDetails + filledForm = bankDetails.flatMap(_.details).fold(enterBankAccountDetailsForm)(enterBankAccountDetailsForm.fill) + } yield Ok(view(filledForm)) + } } def submit: Action[AnyContent] = isAuthenticatedWithProfile { @@ -51,13 +56,17 @@ class UkBankAccountDetailsController @Inject()(val authConnector: AuthClientConn formWithErrors => Future.successful(BadRequest(view(formWithErrors))), accountDetails => - bankAccountDetailsService.saveEnteredBankAccountDetails(accountDetails).map { accountDetailsValid => + bankAccountDetailsService.saveEnteredBankAccountDetails(accountDetails).flatMap { accountDetailsValid => if (accountDetailsValid) { - Redirect(controllers.routes.TaskListController.show.url) - } - else { - val invalidDetails = EnterBankAccountDetailsForm.formWithInvalidAccountReputation.fill(accountDetails) - BadRequest(view(invalidDetails)) + Future.successful(Redirect(controllers.routes.TaskListController.show.url)) + } else { + lockService.incrementBarsAttempts(profile.registrationId).map { attempts => + if (attempts >= appConfig.knownFactsLockAttemptLimit) { + Redirect(controllers.errors.routes.ThirdAttemptLockoutController.show) + } else { + Redirect(controllers.bankdetails.routes.AccountDetailsNotVerified.show) + } + } } } ) diff --git a/app/controllers/errors/ThirdAttemptLockoutController.scala b/app/controllers/errors/ThirdAttemptLockoutController.scala new file mode 100644 index 000000000..2255ae189 --- /dev/null +++ b/app/controllers/errors/ThirdAttemptLockoutController.scala @@ -0,0 +1,42 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers.errors + + +import play.api.mvc.{Action, AnyContent, MessagesControllerComponents} +import uk.gov.hmrc.auth.core.{AuthConnector, AuthorisedFunctions} +import config.FrontendAppConfig +import uk.gov.hmrc.play.bootstrap.frontend.controller.FrontendController +import services.SessionService +import views.html.errors.ThirdAttemptLockoutPage + +import javax.inject.{Inject, Singleton} +import scala.concurrent.{ExecutionContext, Future} + +@Singleton +class ThirdAttemptLockoutController @Inject()(mcc: MessagesControllerComponents, + view: ThirdAttemptLockoutPage, + val authConnector: AuthConnector + )(implicit appConfig: FrontendAppConfig, ec: ExecutionContext) extends FrontendController(mcc) with AuthorisedFunctions { + + def show(): Action[AnyContent] = Action.async { + implicit request => + authorised() { + Future.successful(Ok(view())) + } + } +} \ No newline at end of file diff --git a/app/models/Lock.scala b/app/models/Lock.scala new file mode 100644 index 000000000..e6993794c --- /dev/null +++ b/app/models/Lock.scala @@ -0,0 +1,31 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package models + +import play.api.libs.json.{Format, Json, OFormat} +import uk.gov.hmrc.mongo.play.json.formats.MongoJavatimeFormats + +import java.time.Instant + +case class Lock(identifier: String, + failedAttempts: Int, + lastAttemptedAt: Instant) + +object Lock { + implicit val instantFormat: Format[Instant] = MongoJavatimeFormats.instantFormat + implicit lazy val format: OFormat[Lock] = Json.format +} \ No newline at end of file diff --git a/app/repositories/UserLockRepository.scala b/app/repositories/UserLockRepository.scala new file mode 100644 index 000000000..f5942abc2 --- /dev/null +++ b/app/repositories/UserLockRepository.scala @@ -0,0 +1,108 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package repositories + +import org.mongodb.scala.model.Indexes.ascending +import org.mongodb.scala.model._ +import play.api.libs.json._ +import config.FrontendAppConfig +import models.Lock +import models.Lock._ +import uk.gov.hmrc.mongo.MongoComponent +import uk.gov.hmrc.mongo.play.json.PlayMongoRepository + +import java.time.Instant +import java.util.concurrent.TimeUnit +import javax.inject.{Inject, Singleton} +import scala.concurrent.{ExecutionContext, Future} + +@Singleton +class UserLockRepository @Inject()( + mongoComponent: MongoComponent, + appConfig: FrontendAppConfig + )(implicit ec: ExecutionContext) extends PlayMongoRepository[Lock]( + collectionName = "user-lock", + mongoComponent = mongoComponent, + domainFormat = implicitly[Format[Lock]], + indexes = Seq( + IndexModel( + keys = ascending("lastAttemptedAt"), + indexOptions = IndexOptions() + .name("CVEInvalidDataLockExpires") + .expireAfter(appConfig.ttlLockSeconds, TimeUnit.SECONDS) + ), + IndexModel( + keys = ascending("identifier"), + indexOptions = IndexOptions() + .name("IdentifierIdx") + .sparse(true) + .unique(true) + ) + ), + replaceIndexes = true +) { + + def getFailedAttempts(identifier: String): Future[Int] = + collection + .find(Filters.eq("identifier", identifier)) + .headOption() + .map(_.map(_.failedAttempts).getOrElse(0)) + + def isUserLocked(userId: String): Future[Boolean] = { + collection + .find(Filters.in("identifier", userId)) + .toFuture() + .map { _.exists { _.failedAttempts >= appConfig.knownFactsLockAttemptLimit }} + } + + def updateAttempts(userId: String): Future[Map[String, Int]] = { + def updateAttemptsForLockWith(identifier: String): Future[Lock] = { + collection + .find(Filters.eq("identifier", identifier)) + .headOption() + .flatMap { + case Some(existingLock) => + val newLock = existingLock.copy( + failedAttempts = existingLock.failedAttempts + 1, + lastAttemptedAt = Instant.now() + ) + collection.replaceOne( + Filters.and( + Filters.eq("identifier", identifier) + ), + newLock + ) + .toFuture() + .map(_ => newLock) + case _ => + val newLock = Lock(identifier, 1, Instant.now) + collection.insertOne(newLock) + .toFuture() + .map(_ => newLock) + } + } + val updateUserLock = updateAttemptsForLockWith(userId) + + for { + userLock <- updateUserLock + } yield { + Map( + "user" -> userLock.failedAttempts + ) + } + } +} \ No newline at end of file diff --git a/app/services/LockService.scala b/app/services/LockService.scala new file mode 100644 index 000000000..49f67ad24 --- /dev/null +++ b/app/services/LockService.scala @@ -0,0 +1,62 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package services + + +import play.api.mvc.Result +import play.api.mvc.Results.Redirect +import config.FrontendAppConfig +import controllers.errors +import repositories.UserLockRepository +import utils.LoggingUtil + +import javax.inject.{Inject, Singleton} +import scala.concurrent.{ExecutionContext, Future} + +@Singleton +class LockService @Inject()(userLockRepository: UserLockRepository, + config: FrontendAppConfig)(implicit ec: ExecutionContext) extends LoggingUtil{ + + + + def updateAttempts(userId: String): Future[Map[String, Int]] = { + if (config.isKnownFactsCheckEnabled) { + userLockRepository.updateAttempts(userId) + } else { + Future.successful(Map.empty) + } + } + + def isJourneyLocked(userId: String): Future[Boolean] = { + if (config.isKnownFactsCheckEnabled) { + userLockRepository.isUserLocked(userId) + } else { + Future.successful(false) + } + } + + // ---- BARs bank account lock methods ---- + + def getBarsAttemptsUsed(registrationId: String): Future[Int] = + userLockRepository.getFailedAttempts(registrationId) + + def incrementBarsAttempts(registrationId: String): Future[Int] = + userLockRepository.updateAttempts(registrationId).map(_.getOrElse("user", 0)) + + def isBarsLocked(registrationId: String): Future[Boolean] = + userLockRepository.isUserLocked(registrationId) +} diff --git a/app/views/bankdetails/AccountDetailsNotVerifiedView.scala.html b/app/views/bankdetails/AccountDetailsNotVerifiedView.scala.html new file mode 100644 index 000000000..82bebb867 --- /dev/null +++ b/app/views/bankdetails/AccountDetailsNotVerifiedView.scala.html @@ -0,0 +1,57 @@ +@* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + *@ + +@import config.FrontendAppConfig +@import play.api.data.Form +@import play.api.i18n.Messages +@import play.api.mvc.Request +@import uk.gov.hmrc.govukfrontend.views.html.components._ + +@this( + layout: layouts.layout, + h1: components.h1, + p: components.p, + formWithCSRF: FormWithCSRF, + errorSummary: components.errorSummary, +) + +@(form: Form[Boolean])(implicit request: Request[_], messages: Messages, appConfig: FrontendAppConfig) + + +@layout(pageTitle = Some(title(form, messages("pages.accountDetailsCouldNotBeVerified.heading")))){ + + @errorSummary(errors = form.errors) + + @h1("pages.accountDetailsCouldNotBeVerified.heading") + + @p { + @messages("pages.accountDetailsCouldNotBeVerified.para1", 3 - form.data.get("attempts").map(_.toInt).getOrElse(0)) + } + + @p { + @Html(messages("pages.accountDetailsCouldNotBeVerified.para2", appConfig.vatTaskList)) + } +@* + * @p { + * @messages("pages.accountDetailsCouldNotBeVerified.para2") + * } +*@ + + @p { + @messages("pages.accountDetailsCouldNotBeVerified.para3.bold") @messages("pages.accountDetailsCouldNotBeVerified.para3") + } + +} \ No newline at end of file diff --git a/app/views/errors/ThirdAttemptLockoutPage.scala.html b/app/views/errors/ThirdAttemptLockoutPage.scala.html new file mode 100644 index 000000000..d348775a8 --- /dev/null +++ b/app/views/errors/ThirdAttemptLockoutPage.scala.html @@ -0,0 +1,49 @@ +@* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + *@ + +@import config.FrontendAppConfig +@import play.api.i18n.Messages +@import play.api.mvc.Request +@import views.html.components._ +@import views.html.layouts.layout +@import uk.gov.hmrc.govukfrontend.views.html.components._ + + +@this(layout: layout, + h1: h1, + p: p, + govUkHeader: GovukHeader, + govukButton: GovukButton +) + +@()(implicit request: Request[_], messages: Messages, appConfig: FrontendAppConfig) + +@layout(Some(titleNoForm(messages("ThirdAttemptLockoutPage.heading"))), backLink = false) { + + @h1(messages("ThirdAttemptLockoutPage.heading")) + + @p { + @messages("pages.ThirdAttemptLockoutPage.para1") + } + + @p { + @messages("pages.ThirdAttemptLockoutPage.para2") + } + + @p { + @Html(messages("pages.ThirdAttemptLockoutPage.para3", appConfig.vatTaskList)) + } +} \ No newline at end of file diff --git a/conf/messages b/conf/messages index dd8a986f1..81e17557a 100644 --- a/conf/messages +++ b/conf/messages @@ -2028,3 +2028,14 @@ partnerEmail.error.incorrect_format = Enter the email address in the partnerEmail.error.nothing_entered = Enter the email address partnerEmail.error.incorrect_length = The email address must be 132 characters or fewer +#Account Details could not be verified +pages.accountDetailsCouldNotBeVerified.heading = We could not verify the bank details you provided +pages.accountDetailsCouldNotBeVerified.para1 = You have {0} more attempts to provide your account details. +pages.accountDetailsCouldNotBeVerified.para2 = Enter your bank account or building society details again, making sure the name matches exactly as it appears on your account. +pages.accountDetailsCouldNotBeVerified.para3.bold = After 3 consecutive unsuccessful attempts, +pages.accountDetailsCouldNotBeVerified.para3 = you will need to complete your VAT registration before sending us your details. + +ThirdAttemptLockoutPage.heading = Account details could not be verified +pages.ThirdAttemptLockoutPage.para1 = We have been unable to verify the account details you supplied. +pages.ThirdAttemptLockoutPage.para2 = For your security, we have paused this part of the service. +pages.ThirdAttemptLockoutPage.para3 = You can return to the VAT registration task list now, and provide your account details later once your registration is confirmed. \ No newline at end of file diff --git a/test/controllers/bankdetails/AccountDetailsNotVerifiedSpec.scala b/test/controllers/bankdetails/AccountDetailsNotVerifiedSpec.scala new file mode 100644 index 000000000..e58c58d10 --- /dev/null +++ b/test/controllers/bankdetails/AccountDetailsNotVerifiedSpec.scala @@ -0,0 +1,87 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers.bankdetails + +import fixtures.VatRegistrationFixture +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito._ +import play.api.test.FakeRequest +import services.LockService +import testHelpers.{ControllerSpec, FutureAssertions} +import views.html.bankdetails.AccountDetailsNotVerifiedView + +import scala.concurrent.Future + +class AccountDetailsNotVerifiedSpec extends ControllerSpec with VatRegistrationFixture with FutureAssertions { + + val mockLockService: LockService = mock[LockService] + val view: AccountDetailsNotVerifiedView = app.injector.instanceOf[AccountDetailsNotVerifiedView] + + trait Setup { + val testController = new AccountDetailsNotVerified( + mockAuthClientConnector, + mockSessionService, + mockLockService, + view + ) + + mockAuthenticated() + mockWithCurrentProfile(Some(currentProfile)) + } + + "show" should { + "return 200 when attempts used is below the lockout limit" in new Setup { + when(mockLockService.getBarsAttemptsUsed(any())) + .thenReturn(Future.successful(1)) + + callAuthorised(testController.show) { result => + status(result) mustBe OK + } + } + + "return 200 when attempts used is one below the lockout limit" in new Setup { + // knownFactsLockAttemptLimit is 3 in appConfig, so 2 attempts should still show the page + when(mockLockService.getBarsAttemptsUsed(any())) + .thenReturn(Future.successful(2)) + + callAuthorised(testController.show) { result => + status(result) mustBe OK + } + } + + "redirect to ThirdAttemptLockout when attempts used is at or above the lockout limit" in new Setup { + when(mockLockService.getBarsAttemptsUsed(any())) + .thenReturn(Future.successful(3)) // >= appConfig.knownFactsLockAttemptLimit (3) + + callAuthorised(testController.show) { result => + status(result) mustBe SEE_OTHER + redirectLocation(result) mustBe Some(controllers.errors.routes.ThirdAttemptLockoutController.show.url) + } + } + + "redirect to ThirdAttemptLockout when attempts used exceeds the lockout limit" in new Setup { + when(mockLockService.getBarsAttemptsUsed(any())) + .thenReturn(Future.successful(5)) + + callAuthorised(testController.show) { result => + status(result) mustBe SEE_OTHER + redirectLocation(result) mustBe Some(controllers.errors.routes.ThirdAttemptLockoutController.show.url) + } + } + } +} + diff --git a/test/controllers/errors/ThirdAttemptLockoutControllerSpec.scala b/test/controllers/errors/ThirdAttemptLockoutControllerSpec.scala new file mode 100644 index 000000000..1d5ac5965 --- /dev/null +++ b/test/controllers/errors/ThirdAttemptLockoutControllerSpec.scala @@ -0,0 +1,53 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers.errors + +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito._ +import play.api.test.FakeRequest +import testHelpers.{ControllerSpec, FutureAssertions} +import views.html.errors.ThirdAttemptLockoutPage + +import scala.concurrent.Future + +class ThirdAttemptLockoutControllerSpec extends ControllerSpec with FutureAssertions { + + val view: ThirdAttemptLockoutPage = app.injector.instanceOf[ThirdAttemptLockoutPage] + + trait Setup { + val testController = new ThirdAttemptLockoutController( + messagesControllerComponents, + view, + mockAuthConnector + ) + + // ThirdAttemptLockoutController uses AuthorisedFunctions which calls authConnector.authorise + // directly, so we stub mockAuthConnector (uk.gov.hmrc.auth.core.AuthConnector) rather than + // mockAuthClientConnector. + when(mockAuthConnector.authorise[Unit](any(), any())(any(), any())) + .thenReturn(Future.successful(())) + } + + "show" should { + "return 200 and render the lockout page" in new Setup { + val result = testController.show()(FakeRequest()) + status(result) mustBe OK + contentType(result) mustBe Some("text/html") + } + } +} + diff --git a/test/services/LockServiceSpec.scala b/test/services/LockServiceSpec.scala new file mode 100644 index 000000000..bd354b5c9 --- /dev/null +++ b/test/services/LockServiceSpec.scala @@ -0,0 +1,102 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package services + +import config.FrontendAppConfig +import org.mockito.ArgumentMatchers.{any, eq => eqTo} +import org.mockito.Mockito._ +import org.scalatestplus.mockito.MockitoSugar +import org.scalatestplus.play.PlaySpec +import play.api.test.{DefaultAwaitTimeout, FutureAwaits} +import repositories.UserLockRepository + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +class LockServiceSpec extends PlaySpec with MockitoSugar with FutureAwaits with DefaultAwaitTimeout { + + val mockUserLockRepository: UserLockRepository = mock[UserLockRepository] + val mockAppConfig: FrontendAppConfig = mock[FrontendAppConfig] + + val registrationId = "reg-123" + + trait Setup { + val service: LockService = new LockService(mockUserLockRepository, mockAppConfig) + } + + // ---- getBarsAttemptsUsed ---- + + "getBarsAttemptsUsed" should { + "return the number of failed attempts from the repository" in new Setup { + when(mockUserLockRepository.getFailedAttempts(eqTo(registrationId))) + .thenReturn(Future.successful(2)) + + await(service.getBarsAttemptsUsed(registrationId)) mustBe 2 + } + + "return 0 when there are no recorded attempts" in new Setup { + when(mockUserLockRepository.getFailedAttempts(eqTo(registrationId))) + .thenReturn(Future.successful(0)) + + await(service.getBarsAttemptsUsed(registrationId)) mustBe 0 + } + } + + // ---- incrementBarsAttempts ---- + + "incrementBarsAttempts" should { + "return the new total number of failed attempts after incrementing" in new Setup { + when(mockUserLockRepository.updateAttempts(eqTo(registrationId))) + .thenReturn(Future.successful(Map("user" -> 1))) + + await(service.incrementBarsAttempts(registrationId)) mustBe 1 + } + + "return 0 if the repository map does not contain the 'user' key" in new Setup { + when(mockUserLockRepository.updateAttempts(eqTo(registrationId))) + .thenReturn(Future.successful(Map.empty[String, Int])) + + await(service.incrementBarsAttempts(registrationId)) mustBe 0 + } + + "return 3 on the third failed attempt" in new Setup { + when(mockUserLockRepository.updateAttempts(eqTo(registrationId))) + .thenReturn(Future.successful(Map("user" -> 3))) + + await(service.incrementBarsAttempts(registrationId)) mustBe 3 + } + } + + // ---- isBarsLocked ---- + + "isBarsLocked" should { + "return true when the user is locked in the repository" in new Setup { + when(mockUserLockRepository.isUserLocked(eqTo(registrationId))) + .thenReturn(Future.successful(true)) + + await(service.isBarsLocked(registrationId)) mustBe true + } + + "return false when the user is not locked in the repository" in new Setup { + when(mockUserLockRepository.isUserLocked(eqTo(registrationId))) + .thenReturn(Future.successful(false)) + + await(service.isBarsLocked(registrationId)) mustBe false + } + } +} + diff --git a/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala b/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala index a2768174f..02eea18f8 100644 --- a/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala +++ b/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala @@ -27,14 +27,15 @@ class HasCompanyBankAccountViewSpec extends VatRegViewSpec { implicit val doc: Document = Jsoup.parse(view(HasCompanyBankAccountForm.form).body) lazy val view: HasCompanyBankAccountView = app.injector.instanceOf[HasCompanyBankAccountView] - val heading = "Are you able to provide bank or building society account details for the business?" + val heading = "Can you provide bank or building society details for VAT repayments to the business?" val title = s"$heading - Register for VAT - GOV.UK" - val para = "If we owe the business money, we can repay this directly into your bank by BACS. This is faster and more secure than HMRC cheques." - val para2 = "The account does not have to be a dedicated business account but it must be:" - val bullet1 = "separate from a personal account" - val bullet2 = "in the name of the registered person or company" - val bullet3 = "in the UK" - val bullet4 = "able to receive BACS payments" + val para = "If HMRC owes the business money, it will repay this directly to your account." + val para2 = "BACS is normally used for VAT repayments because this is a faster and more secure payment method than a cheque." + val para3 = "The account you select to receive VAT repayments must be:" + val bullet1 = "Used only for this business" + val bullet2 = "In the name of the individual or company registering for VAT" + val bullet3 = "Based in the UK" + val bullet4 = "Able to receive BACS payments" val yes = "Yes" val no = "No" val continue = "Save and continue" @@ -55,6 +56,7 @@ class HasCompanyBankAccountViewSpec extends VatRegViewSpec { "have correct text" in new ViewSetup { doc.para(1) mustBe Some(para) doc.para(2) mustBe Some(para2) + doc.para(3) mustBe Some(para3) } "have correct bullets" in new ViewSetup { From a536a366f8707731324c5e38e1e51133274e365c Mon Sep 17 00:00:00 2001 From: Hugo Greenwood <183600681+HG-Accenture@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:47:48 +0000 Subject: [PATCH 02/27] Role back headings and paragraphs in bank details view spec --- .../HasCompanyBankAccountViewSpec.scala | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala b/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala index 02eea18f8..a2768174f 100644 --- a/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala +++ b/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala @@ -27,15 +27,14 @@ class HasCompanyBankAccountViewSpec extends VatRegViewSpec { implicit val doc: Document = Jsoup.parse(view(HasCompanyBankAccountForm.form).body) lazy val view: HasCompanyBankAccountView = app.injector.instanceOf[HasCompanyBankAccountView] - val heading = "Can you provide bank or building society details for VAT repayments to the business?" + val heading = "Are you able to provide bank or building society account details for the business?" val title = s"$heading - Register for VAT - GOV.UK" - val para = "If HMRC owes the business money, it will repay this directly to your account." - val para2 = "BACS is normally used for VAT repayments because this is a faster and more secure payment method than a cheque." - val para3 = "The account you select to receive VAT repayments must be:" - val bullet1 = "Used only for this business" - val bullet2 = "In the name of the individual or company registering for VAT" - val bullet3 = "Based in the UK" - val bullet4 = "Able to receive BACS payments" + val para = "If we owe the business money, we can repay this directly into your bank by BACS. This is faster and more secure than HMRC cheques." + val para2 = "The account does not have to be a dedicated business account but it must be:" + val bullet1 = "separate from a personal account" + val bullet2 = "in the name of the registered person or company" + val bullet3 = "in the UK" + val bullet4 = "able to receive BACS payments" val yes = "Yes" val no = "No" val continue = "Save and continue" @@ -56,7 +55,6 @@ class HasCompanyBankAccountViewSpec extends VatRegViewSpec { "have correct text" in new ViewSetup { doc.para(1) mustBe Some(para) doc.para(2) mustBe Some(para2) - doc.para(3) mustBe Some(para3) } "have correct bullets" in new ViewSetup { From 43a5684839fd83e4d87c3aec010c166cb5d8a932 Mon Sep 17 00:00:00 2001 From: HG-Accenture <183600681+HG-Accenture@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:10:14 +0000 Subject: [PATCH 03/27] Add lock service routes --- conf/app.routes | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/conf/app.routes b/conf/app.routes index 7254020a3..4a8628d5f 100644 --- a/conf/app.routes +++ b/conf/app.routes @@ -631,3 +631,7 @@ POST /partner/:index/partner-telephone controller ## Partner Email Address Page GET /partner/:index/email-address controllers.partners.PartnerCaptureEmailAddressController.show(index: Int) POST /partner/:index/email-address controllers.partners.PartnerCaptureEmailAddressController.submit(index: Int) + +# Lockout screens +GET /failed-third-attempt controllers.errors.ThirdAttemptLockoutController.show +GET /failed-attempt controllers.bankdetails.AccountDetailsNotVerified.show \ No newline at end of file From 7c3a2845e4db15074f944959bf3c00581fb0dcac Mon Sep 17 00:00:00 2001 From: Muhammad Ibraaheem <254715997+mi-aspectratio@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:10:44 +0000 Subject: [PATCH 04/27] DL-18572 Add RollNumber to Enter Details Page (#1229) * feat: add rollnumber, add tests and add check your details page, add encrypted session details cache. rename save and fetch methods in details service --- .../EnterBankAccountDetailsA11ySpec.scala | 27 ++ .../UkBankAccountDetailsA11ySpec.scala | 8 +- app/config/startup/VerifyCrypto.scala | 2 +- .../ChooseAccountTypeController.scala | 2 +- .../HasBankAccountController.scala | 2 +- .../NoUKBankAccountController.scala | 2 +- .../UkBankAccountDetailsController.scala | 93 ++++-- app/forms/BankAccountDetailsForms.scala | 87 +++++- app/models/BankAccountDetails.scala | 15 +- .../BankAccountDetailsSessionFormat.scala | 45 +++ app/services/BankAccountDetailsService.scala | 65 +++-- .../EnterBankAccountDetails.scala.html | 83 ++++++ .../EnterCompanyBankAccountDetails.scala.html | 8 +- conf/app.routes | 2 +- conf/application.conf | 5 + conf/messages | 46 +-- conf/messages.cy | 27 +- .../UKBankAccountDetailsControllerISpec.scala | 268 ++++++++++++----- .../itFixtures/ITRegistrationFixtures.scala | 3 +- test/fixtures/VatRegistrationFixture.scala | 2 +- test/forms/BankAccountDetailsFormSpec.scala | 270 +++++++++++++++--- test/models/BankAccountDetailsSpec.scala | 20 +- .../BankAccountDetailsSessionFormatSpec.scala | 107 +++++++ .../BankAccountDetailsServiceSpec.scala | 10 +- .../CompanyBankDetailsViewSpec.scala | 4 +- .../EnterBankDetailsViewSpec.scala | 97 +++++++ 26 files changed, 1066 insertions(+), 234 deletions(-) create mode 100644 a11y/pages/bankdetails/EnterBankAccountDetailsA11ySpec.scala create mode 100644 app/models/bars/BankAccountDetailsSessionFormat.scala create mode 100644 app/views/bankdetails/EnterBankAccountDetails.scala.html create mode 100644 test/models/bars/BankAccountDetailsSessionFormatSpec.scala create mode 100644 test/views/bankdetails/EnterBankDetailsViewSpec.scala diff --git a/a11y/pages/bankdetails/EnterBankAccountDetailsA11ySpec.scala b/a11y/pages/bankdetails/EnterBankAccountDetailsA11ySpec.scala new file mode 100644 index 000000000..556edd984 --- /dev/null +++ b/a11y/pages/bankdetails/EnterBankAccountDetailsA11ySpec.scala @@ -0,0 +1,27 @@ +package pages.bankdetails + +import forms.EnterBankAccountDetailsForm +import helpers.A11ySpec +import models.BankAccountDetails +import play.api.data.Form +import views.html.bankdetails.EnterBankAccountDetails + +class EnterBankAccountDetailsA11ySpec extends A11ySpec { + + val view: EnterBankAccountDetails = app.injector.instanceOf[EnterBankAccountDetails] + val form: Form[BankAccountDetails] = EnterBankAccountDetailsForm.form + + "the Enter Bank Account Details page" when { + "there are no form errors" must { + "pass all a11y checks" in { + view(form).body must passAccessibilityChecks + } + } + "there are form errors" must { + "pass all a11y checks" in { + view(form.bind(Map("" -> ""))).body must passAccessibilityChecks + } + } + } + +} diff --git a/a11y/pages/bankdetails/UkBankAccountDetailsA11ySpec.scala b/a11y/pages/bankdetails/UkBankAccountDetailsA11ySpec.scala index 1914f236d..2b5c4f0bd 100644 --- a/a11y/pages/bankdetails/UkBankAccountDetailsA11ySpec.scala +++ b/a11y/pages/bankdetails/UkBankAccountDetailsA11ySpec.scala @@ -3,12 +3,14 @@ package pages.bankdetails import helpers.A11ySpec import views.html.bankdetails.EnterCompanyBankAccountDetails -import forms.EnterBankAccountDetailsForm +import forms.EnterCompanyBankAccountDetailsForm +import models.BankAccountDetails +import play.api.data.Form class UkBankAccountDetailsA11ySpec extends A11ySpec { - val view = app.injector.instanceOf[EnterCompanyBankAccountDetails] - val form = EnterBankAccountDetailsForm.form + val view: EnterCompanyBankAccountDetails = app.injector.instanceOf[EnterCompanyBankAccountDetails] + val form: Form[BankAccountDetails] = EnterCompanyBankAccountDetailsForm.form "the Enter Company Bank Account Details page" when { "there are no form errors" must { diff --git a/app/config/startup/VerifyCrypto.scala b/app/config/startup/VerifyCrypto.scala index c074d4215..e0ee6cc0b 100644 --- a/app/config/startup/VerifyCrypto.scala +++ b/app/config/startup/VerifyCrypto.scala @@ -16,7 +16,7 @@ package config.startup -import uk.gov.hmrc.crypto.ApplicationCrypto +import uk.gov.hmrc.play.bootstrap.frontend.filters.crypto.ApplicationCrypto import javax.inject.{Inject, Singleton} diff --git a/app/controllers/bankdetails/ChooseAccountTypeController.scala b/app/controllers/bankdetails/ChooseAccountTypeController.scala index c0d76ead0..1de0d142d 100644 --- a/app/controllers/bankdetails/ChooseAccountTypeController.scala +++ b/app/controllers/bankdetails/ChooseAccountTypeController.scala @@ -39,7 +39,7 @@ class ChooseAccountTypeController @Inject() (val authConnector: AuthClientConnec def show: Action[AnyContent] = isAuthenticatedWithProfile { implicit request => implicit profile => if (isEnabled(UseNewBarsVerify)) { - bankAccountDetailsService.fetchBankAccountDetails.map { bankDetails => + bankAccountDetailsService.getBankAccount.map { bankDetails => val filledForm = bankDetails .flatMap(_.bankAccountType) .fold(ChooseAccountTypeForm.form)(ChooseAccountTypeForm.form.fill) diff --git a/app/controllers/bankdetails/HasBankAccountController.scala b/app/controllers/bankdetails/HasBankAccountController.scala index d843e2615..704846c66 100644 --- a/app/controllers/bankdetails/HasBankAccountController.scala +++ b/app/controllers/bankdetails/HasBankAccountController.scala @@ -45,7 +45,7 @@ class HasBankAccountController @Inject()(val authConnector: AuthClientConnector, Future.successful(Redirect(controllers.flatratescheme.routes.JoinFlatRateSchemeController.show)) case _ => for { - bankDetails <- bankAccountDetailsService.fetchBankAccountDetails + bankDetails <- bankAccountDetailsService.getBankAccount filledForm = bankDetails.map(_.isProvided).fold(hasBankAccountForm)(hasBankAccountForm.fill) } yield Ok(view(filledForm)) } diff --git a/app/controllers/bankdetails/NoUKBankAccountController.scala b/app/controllers/bankdetails/NoUKBankAccountController.scala index 24d230e51..0a2639ee9 100644 --- a/app/controllers/bankdetails/NoUKBankAccountController.scala +++ b/app/controllers/bankdetails/NoUKBankAccountController.scala @@ -40,7 +40,7 @@ class NoUKBankAccountController @Inject()(noUKBankAccountView: NoUkBankAccount, implicit request => implicit profile => for { - optBankAccountDetails <- bankAccountDetailsService.fetchBankAccountDetails + optBankAccountDetails <- bankAccountDetailsService.getBankAccount form = optBankAccountDetails.flatMap(_.reason).fold(NoUKBankAccountForm.form)(NoUKBankAccountForm.form.fill) } yield Ok(noUKBankAccountView(form)) } diff --git a/app/controllers/bankdetails/UkBankAccountDetailsController.scala b/app/controllers/bankdetails/UkBankAccountDetailsController.scala index 4a7b2c48e..58bff9740 100644 --- a/app/controllers/bankdetails/UkBankAccountDetailsController.scala +++ b/app/controllers/bankdetails/UkBankAccountDetailsController.scala @@ -18,49 +18,80 @@ package controllers.bankdetails import config.{AuthClientConnector, BaseControllerComponents, FrontendAppConfig} import controllers.BaseController +import featuretoggle.FeatureSwitch.UseNewBarsVerify +import featuretoggle.FeatureToggleSupport.isEnabled +import forms.EnterCompanyBankAccountDetailsForm +import forms.EnterCompanyBankAccountDetailsForm.{form => enterBankAccountDetailsForm} import forms.EnterBankAccountDetailsForm -import forms.EnterBankAccountDetailsForm.{form => enterBankAccountDetailsForm} +import models.BankAccountDetails +import models.bars.BankAccountDetailsSessionFormat +import play.api.libs.json.Format import play.api.mvc.{Action, AnyContent} +import play.api.Configuration import services.{BankAccountDetailsService, SessionService} +import uk.gov.hmrc.crypto.SymmetricCryptoFactory import views.html.bankdetails.EnterCompanyBankAccountDetails +import views.html.bankdetails.EnterBankAccountDetails import javax.inject.Inject import scala.concurrent.{ExecutionContext, Future} -class UkBankAccountDetailsController @Inject()(val authConnector: AuthClientConnector, - val bankAccountDetailsService: BankAccountDetailsService, - val sessionService: SessionService, - view: EnterCompanyBankAccountDetails) - (implicit appConfig: FrontendAppConfig, - val executionContext: ExecutionContext, - baseControllerComponents: BaseControllerComponents) extends BaseController { +class UkBankAccountDetailsController @Inject() ( + val authConnector: AuthClientConnector, + val bankAccountDetailsService: BankAccountDetailsService, + val sessionService: SessionService, + configuration: Configuration, + newBarsView: EnterBankAccountDetails, + oldView: EnterCompanyBankAccountDetails + )(implicit appConfig: FrontendAppConfig, val executionContext: ExecutionContext, baseControllerComponents: BaseControllerComponents) + extends BaseController { - def show: Action[AnyContent] = isAuthenticatedWithProfile { - implicit request => - implicit profile => - for { - bankDetails <- bankAccountDetailsService.fetchBankAccountDetails - filledForm = bankDetails.flatMap(_.details).fold(enterBankAccountDetailsForm)(enterBankAccountDetailsForm.fill) - } yield Ok(view(filledForm)) + private val encrypter = + SymmetricCryptoFactory.aesCryptoFromConfig("json.encryption", configuration.underlying) + + private implicit val encryptedFormat: Format[BankAccountDetails] = + BankAccountDetailsSessionFormat.format(encrypter) + + private val sessionKey = "bankAccountDetails" + + def show: Action[AnyContent] = isAuthenticatedWithProfile { implicit request => implicit profile => + if (isEnabled(UseNewBarsVerify)) { + val newBarsForm = EnterBankAccountDetailsForm.form + sessionService.fetchAndGet[BankAccountDetails](sessionKey).map { + case Some(details) => Ok(newBarsView(newBarsForm.fill(details))) + case None => Ok(newBarsView(newBarsForm)) + } + } else { + for { + bankDetails <- bankAccountDetailsService.getBankAccount + filledForm = bankDetails.flatMap(_.details).fold(enterBankAccountDetailsForm)(enterBankAccountDetailsForm.fill) + } yield Ok(oldView(filledForm)) + } } - def submit: Action[AnyContent] = isAuthenticatedWithProfile { - implicit request => - implicit profile => - enterBankAccountDetailsForm.bindFromRequest().fold( - formWithErrors => - Future.successful(BadRequest(view(formWithErrors))), + def submit: Action[AnyContent] = isAuthenticatedWithProfile { implicit request => implicit profile => + if (isEnabled(UseNewBarsVerify)) { + val newBarsForm = EnterBankAccountDetailsForm.form + newBarsForm + .bindFromRequest() + .fold( + formWithErrors => Future.successful(BadRequest(newBarsView(formWithErrors))), accountDetails => - bankAccountDetailsService.saveEnteredBankAccountDetails(accountDetails).map { accountDetailsValid => - if (accountDetailsValid) { - Redirect(controllers.routes.TaskListController.show.url) - } - else { - val invalidDetails = EnterBankAccountDetailsForm.formWithInvalidAccountReputation.fill(accountDetails) - BadRequest(view(invalidDetails)) - } + sessionService.cache[BankAccountDetails](sessionKey, accountDetails).map { _ => + Redirect(controllers.routes.TaskListController.show.url) } ) + } else { + enterBankAccountDetailsForm + .bindFromRequest() + .fold( + formWithErrors => Future.successful(BadRequest(oldView(formWithErrors))), + accountDetails => + bankAccountDetailsService.saveEnteredBankAccountDetails(accountDetails).map { + case true => Redirect(controllers.routes.TaskListController.show.url) + case false => BadRequest(oldView(EnterCompanyBankAccountDetailsForm.formWithInvalidAccountReputation.fill(accountDetails))) + } + ) + } } - -} +} \ No newline at end of file diff --git a/app/forms/BankAccountDetailsForms.scala b/app/forms/BankAccountDetailsForms.scala index bfe9a8f77..8dd7cb038 100644 --- a/app/forms/BankAccountDetailsForms.scala +++ b/app/forms/BankAccountDetailsForms.scala @@ -40,28 +40,31 @@ object ChooseAccountTypeForm { val form: Form[BankAccountType] = Form( single( - BUSINESS_OR_PERSONAL_ACCOUNT_RADIO -> default(text, "").verifying(stopOnFail( - mandatory(errorMsg) - )).transform[BankAccountType]( - s => BankAccountType.fromString(s).get, - _.asBars - ) + BUSINESS_OR_PERSONAL_ACCOUNT_RADIO -> default(text, "") + .verifying( + stopOnFail( + mandatory(errorMsg) + )) + .transform[BankAccountType]( + s => BankAccountType.fromString(s).get, + _.asBars + ) ) ) } -object EnterBankAccountDetailsForm { +object EnterCompanyBankAccountDetailsForm { val ACCOUNT_NAME = "accountName" val ACCOUNT_NUMBER = "accountNumber" val SORT_CODE = "sortCode" - val accountNameEmptyKey = "validation.companyBankAccount.name.missing" + val accountNameEmptyKey = "validation.companyBankAccount.name.missing.old" val accountNameMaxLengthKey = "validation.companyBankAccount.name.maxLength" - val accountNameInvalidKey = "validation.companyBankAccount.name.invalid" + val accountNameInvalidKey = "validation.companyBankAccount.name.invalid.old" val accountNumberEmptyKey = "validation.companyBankAccount.number.missing" val accountNumberInvalidKey = "validation.companyBankAccount.number.invalid" - val sortCodeEmptyKey = "validation.companyBankAccount.sortCode.missing" + val sortCodeEmptyKey = "validation.companyBankAccount.sortCode.missing.old" val sortCodeInvalidKey = "validation.companyBankAccount.sortCode.invalid" val invalidAccountReputationKey = "sortCodeAndAccountGroup" @@ -95,12 +98,72 @@ object EnterBankAccountDetailsForm { matchesRegex(sortCodeRegex, sortCodeInvalidKey) )) )((accountName, accountNumber, sortCode) => BankAccountDetails.apply(accountName, accountNumber, sortCode, None))(bankAccountDetails => - BankAccountDetails.unapply(bankAccountDetails).map { case (accountName, accountNumber, sortCode, _) => + BankAccountDetails.unapply(bankAccountDetails).map { case (accountName, accountNumber, sortCode, _, _) => (accountName, accountNumber, sortCode) }) ) - val formWithInvalidAccountReputation: Form[BankAccountDetails] = form.withError(invalidAccountReputationKey, invalidAccountReputationMessage) +} +object EnterBankAccountDetailsForm { + + val ACCOUNT_NAME = "accountName" + val ACCOUNT_NUMBER = "accountNumber" + val SORT_CODE = "sortCode" + val ROLL_NUMBER = "rollNumber" + + val accountNameEmptyKey = "validation.companyBankAccount.name.missing.new" + val accountNameMaxLengthKey = "validation.companyBankAccount.name.maxLength" + val accountNameInvalidKey = "validation.companyBankAccount.name.invalid.new" + val accountNumberEmptyKey = "validation.companyBankAccount.number.missing" + val accountNumberFormatKey = "validation.companyBankAccount.number.format" + val accountNumberInvalidKey = "validation.companyBankAccount.number.invalid" + val sortCodeEmptyKey = "validation.companyBankAccount.sortCode.missing.new" + val sortCodeLengthKey = "validation.companyBankAccount.sortCode.length" + val sortCodeFormatKey = "validation.companyBankAccount.sortCode.format" + val rollNumberInvalidKey = "validation.companyBankAccount.rollNumber.invalid" + + private val accountNameRegex = """^[A-Za-z0-9 '\-./]{1,60}$""".r + private val accountNameMaxLength = 60 + private val rollNumberMaxLength = 25 + private val accountNumberDigitRegex = """^[0-9]+$""".r + private val accountNumberLengthRegex = """^.{6,8}$""".r + private val sortCodeDigitRegex = """^[0-9]+$""".r + private val sortCodeLengthRegex = """^.{6}$""".r + + val form: Form[BankAccountDetails] = Form[BankAccountDetails]( + mapping( + ACCOUNT_NAME -> text.verifying( + stopOnFail( + mandatory(accountNameEmptyKey), + maxLength(accountNameMaxLength, accountNameMaxLengthKey), + matchesRegex(accountNameRegex, accountNameInvalidKey) + )), + ACCOUNT_NUMBER -> text + .transform(removeSpaces, identity[String]) + .verifying(stopOnFail( + mandatory(accountNumberEmptyKey), + matchesRegex(accountNumberDigitRegex, accountNumberFormatKey), + matchesRegex(accountNumberLengthRegex, accountNumberInvalidKey) + )), + SORT_CODE -> text + .transform(removeSpaces, identity[String]) + .verifying( + stopOnFail( + mandatory(sortCodeEmptyKey), + matchesRegex(sortCodeDigitRegex, sortCodeFormatKey), + matchesRegex(sortCodeLengthRegex, sortCodeLengthKey) + )), + ROLL_NUMBER -> optional( + text + .transform(removeSpaces, identity[String]) + .verifying(maxLength(rollNumberMaxLength, rollNumberInvalidKey)) + ) + )((accountName, accountNumber, sortCode, rollNumber) => BankAccountDetails.apply(accountName, accountNumber, sortCode, rollNumber))( + bankAccountDetails => + BankAccountDetails.unapply(bankAccountDetails).map { case (accountName, accountNumber, sortCode, rollNumber, _) => + (accountName, accountNumber, sortCode, rollNumber) + }) + ) } diff --git a/app/models/BankAccountDetails.scala b/app/models/BankAccountDetails.scala index 45dae9ea0..2551f4c1d 100644 --- a/app/models/BankAccountDetails.scala +++ b/app/models/BankAccountDetails.scala @@ -26,7 +26,11 @@ case class BankAccount(isProvided: Boolean, reason: Option[NoUKBankAccount], bankAccountType: Option[BankAccountType] = None) -case class BankAccountDetails(name: String, number: String, sortCode: String, status: Option[BankAccountDetailsStatus] = None) +case class BankAccountDetails(name: String, + number: String, + sortCode: String, + rollNumber: Option[String] = None, + status: Option[BankAccountDetailsStatus] = None) object BankAccountDetails { implicit val accountReputationWrites: OWrites[BankAccountDetails] = new OWrites[BankAccountDetails] { @@ -42,10 +46,11 @@ object BankAccountDetails { def bankSeq(bankAccount: BankAccountDetails): Seq[String] = Seq( - bankAccount.name, - bankAccount.number, - bankAccount.sortCode - ) + Some(bankAccount.name), + Some(bankAccount.number), + Some(bankAccount.sortCode), + bankAccount.rollNumber + ).flatten } object BankAccount { diff --git a/app/models/bars/BankAccountDetailsSessionFormat.scala b/app/models/bars/BankAccountDetailsSessionFormat.scala new file mode 100644 index 000000000..96a86316f --- /dev/null +++ b/app/models/bars/BankAccountDetailsSessionFormat.scala @@ -0,0 +1,45 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package models.bars + +import models.api.BankAccountDetailsStatus +import models.BankAccountDetails +import play.api.libs.functional.syntax._ +import play.api.libs.json._ +import uk.gov.hmrc.crypto.{Decrypter, Encrypter} +import uk.gov.hmrc.crypto.Sensitive.SensitiveString +import uk.gov.hmrc.crypto.json.JsonEncryption + +object BankAccountDetailsSessionFormat { + + def format(encrypter: Encrypter with Decrypter): Format[BankAccountDetails] = { + implicit val crypto: Encrypter with Decrypter = encrypter + + implicit val sensitiveStringFormat: Format[SensitiveString] = + JsonEncryption.sensitiveEncrypterDecrypter(SensitiveString.apply) + + ( + (__ \ "name").format[String] and + (__ \ "sortCode").format[SensitiveString] + .bimap(_.decryptedValue, SensitiveString.apply) and + (__ \ "number").format[SensitiveString] + .bimap(_.decryptedValue, SensitiveString.apply) and + (__ \ "rollNumber").formatNullable[String] and + (__ \ "status").formatNullable[BankAccountDetailsStatus] + )(BankAccountDetails.apply, unlift(BankAccountDetails.unapply)) + } +} \ No newline at end of file diff --git a/app/services/BankAccountDetailsService.scala b/app/services/BankAccountDetailsService.scala index 4cf669d95..63b0b736e 100644 --- a/app/services/BankAccountDetailsService.scala +++ b/app/services/BankAccountDetailsService.scala @@ -18,30 +18,29 @@ package services import connectors.RegistrationApiConnector import models._ -import models.bars.BankAccountType import models.api.{IndeterminateStatus, InvalidStatus, ValidStatus} +import models.bars.BankAccountType +import play.api.mvc.Request import uk.gov.hmrc.http.HeaderCarrier import javax.inject.{Inject, Singleton} import scala.concurrent.{ExecutionContext, Future} -import play.api.mvc.Request @Singleton -class BankAccountDetailsService @Inject()(val regApiConnector: RegistrationApiConnector, - val bankAccountRepService: BankAccountReputationService) { +class BankAccountDetailsService @Inject() (val regApiConnector: RegistrationApiConnector, val bankAccountRepService: BankAccountReputationService) { - def fetchBankAccountDetails(implicit hc: HeaderCarrier, profile: CurrentProfile, request: Request[_]): Future[Option[BankAccount]] = { + def getBankAccount(implicit hc: HeaderCarrier, profile: CurrentProfile, request: Request[_]): Future[Option[BankAccount]] = regApiConnector.getSection[BankAccount](profile.registrationId) - } - def saveBankAccountDetails(bankAccount: BankAccount) - (implicit hc: HeaderCarrier, profile: CurrentProfile, request: Request[_]): Future[BankAccount] = { + def saveBankAccount(bankAccount: BankAccount)(implicit hc: HeaderCarrier, profile: CurrentProfile, request: Request[_]): Future[BankAccount] = regApiConnector.replaceSection[BankAccount](profile.registrationId, bankAccount) - } - def saveHasCompanyBankAccount(hasBankAccount: Boolean) - (implicit hc: HeaderCarrier, profile: CurrentProfile, ex: ExecutionContext, request: Request[_]): Future[BankAccount] = { - val bankAccount = fetchBankAccountDetails map { + def saveHasCompanyBankAccount(hasBankAccount: Boolean)(implicit + hc: HeaderCarrier, + profile: CurrentProfile, + ex: ExecutionContext, + request: Request[_]): Future[BankAccount] = { + val bankAccount = getBankAccount map { case Some(BankAccount(oldHasBankAccount, _, _, _)) if oldHasBankAccount != hasBankAccount => BankAccount(hasBankAccount, None, None, None) case Some(bankAccountDetails) => @@ -50,43 +49,49 @@ class BankAccountDetailsService @Inject()(val regApiConnector: RegistrationApiCo BankAccount(hasBankAccount, None, None, None) } - bankAccount flatMap saveBankAccountDetails + bankAccount flatMap saveBankAccount } - def saveEnteredBankAccountDetails(accountDetails: BankAccountDetails) - (implicit hc: HeaderCarrier, profile: CurrentProfile, ex: ExecutionContext, request: Request[_]): Future[Boolean] = { + def saveEnteredBankAccountDetails(accountDetails: BankAccountDetails)(implicit + hc: HeaderCarrier, + profile: CurrentProfile, + ex: ExecutionContext, + request: Request[_]): Future[Boolean] = for { - existing <- fetchBankAccountDetails - result <- bankAccountRepService.validateBankDetails(accountDetails).flatMap { - case status@(ValidStatus | IndeterminateStatus) => + existing <- getBankAccount + result <- bankAccountRepService.validateBankDetails(accountDetails).flatMap { + case status @ (ValidStatus | IndeterminateStatus) => val bankAccount = BankAccount( isProvided = true, details = Some(accountDetails.copy(status = Some(status))), reason = None, bankAccountType = existing.flatMap(_.bankAccountType) ) - saveBankAccountDetails(bankAccount) map (_ => true) + saveBankAccount(bankAccount) map (_ => true) case InvalidStatus => Future.successful(false) } } yield result - } - def saveNoUkBankAccountDetails(reason: NoUKBankAccount) - (implicit hc: HeaderCarrier, profile: CurrentProfile, request: Request[_]): Future[BankAccount] = { + def saveNoUkBankAccountDetails( + reason: NoUKBankAccount)(implicit hc: HeaderCarrier, profile: CurrentProfile, request: Request[_]): Future[BankAccount] = { val bankAccount = BankAccount( isProvided = false, details = None, reason = Some(reason), bankAccountType = None ) - saveBankAccountDetails(bankAccount) + saveBankAccount(bankAccount) } - def saveBankAccountType(bankAccountType: BankAccountType) - (implicit hc: HeaderCarrier, profile: CurrentProfile, ex: ExecutionContext, request: Request[_]): Future[BankAccount] = { - fetchBankAccountDetails.map { - case Some(existing) => existing.copy(bankAccountType = Some(bankAccountType)) - case None => BankAccount(isProvided = true, details = None, reason = None, bankAccountType = Some(bankAccountType)) - }.flatMap(saveBankAccountDetails) - } + def saveBankAccountType(bankAccountType: BankAccountType)(implicit + hc: HeaderCarrier, + profile: CurrentProfile, + ex: ExecutionContext, + request: Request[_]): Future[BankAccount] = + getBankAccount + .map { + case Some(existing) => existing.copy(bankAccountType = Some(bankAccountType)) + case None => BankAccount(isProvided = true, details = None, reason = None, bankAccountType = Some(bankAccountType)) + } + .flatMap(saveBankAccount) } diff --git a/app/views/bankdetails/EnterBankAccountDetails.scala.html b/app/views/bankdetails/EnterBankAccountDetails.scala.html new file mode 100644 index 000000000..7dc220d1c --- /dev/null +++ b/app/views/bankdetails/EnterBankAccountDetails.scala.html @@ -0,0 +1,83 @@ +@* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + *@ + +@import models.BankAccountDetails +@import play.api.data.Form +@import config.FrontendAppConfig + + +@this( + layout: layouts.layout, + h1: components.h1, + p: components.p, + button: components.button, + formWithCSRF: FormWithCSRF, + errorSummary: components.errorSummary, + panelIndent: components.panelIndent, + inputText: components.inputText +) + +@(bankDetailsForm: Form[BankAccountDetails])(implicit request: Request[_], messages: Messages, appConfig: FrontendAppConfig) + +@layout(pageTitle = Some(title(bankDetailsForm, messages("pages.bankDetails.heading.new")))) { + + @errorSummary(errors = bankDetailsForm.errors) + + @h1("pages.bankDetails.heading.new") + @p { + @messages("pages.bankDetails.p1.new") + } + + @panelIndent { + @messages("pages.bankDetails.info") + } + + @formWithCSRF(action = controllers.bankdetails.routes.UkBankAccountDetailsController.submit) { + @inputText(form = bankDetailsForm, + id = "accountName", + name = "accountName", + label = messages("pages.bankDetails.accountName.label.new"), + classes = Some("govuk-input--width-20"), + isPageHeading = false, + hint = Some(Html(messages("pages.bankDetails.accountName.hint")))) + + @inputText(form = bankDetailsForm, + id = "accountNumber", + name = "accountNumber", + label = messages("pages.bankDetails.accountNumber.label"), + classes = Some("govuk-input--width-10"), + isPageHeading = false, + hint = Some(Html(messages("pages.bankDetails.accountNumber.hint")))) + + @inputText(form = bankDetailsForm, + id = "sortCode", + name = "sortCode", + label = messages("pages.bankDetails.sortCode.label"), + classes = Some("govuk-input--width-5"), + isPageHeading = false, + hint = Some(Html(messages("pages.bankDetails.sortCode.hint")))) + + @inputText(form = bankDetailsForm, + id = "rollNumber", + name = "rollNumber", + label = messages("pages.bankDetails.rollNumber.label"), + classes = Some("govuk-input--width-20"), + isPageHeading = false, + hint = Some(Html(messages("pages.bankDetails.rollNumber.hint")))) + + @button("app.common.continue") + } +} \ No newline at end of file diff --git a/app/views/bankdetails/EnterCompanyBankAccountDetails.scala.html b/app/views/bankdetails/EnterCompanyBankAccountDetails.scala.html index f93cb3b06..b7aedb067 100644 --- a/app/views/bankdetails/EnterCompanyBankAccountDetails.scala.html +++ b/app/views/bankdetails/EnterCompanyBankAccountDetails.scala.html @@ -31,12 +31,12 @@ @(bankDetailsForm: Form[BankAccountDetails])(implicit request: Request[_], messages: Messages, appConfig: FrontendAppConfig) -@layout(pageTitle = Some(title(bankDetailsForm, messages("pages.bankDetails.heading"))), backLink = true) { +@layout(pageTitle = Some(title(bankDetailsForm, messages("pages.bankDetails.heading.old")))) { @errorSummary(errors = bankDetailsForm.errors) - @h1("pages.bankDetails.heading") - @p { @messages("pages.bankDetails.p1") } + @h1("pages.bankDetails.heading.old") + @p { @messages("pages.bankDetails.p1.old") } @panelIndent { @messages("pages.bankDetails.info") @@ -46,7 +46,7 @@ @inputText(form = bankDetailsForm, id = "accountName", name = "accountName", - label = messages("pages.bankDetails.accountName.label"), + label = messages("pages.bankDetails.accountName.label.old"), classes = Some("govuk-input--width-10"), isPageHeading = false, hint = Some(Html(messages("")))) diff --git a/conf/app.routes b/conf/app.routes index 7254020a3..d66ea6c59 100644 --- a/conf/app.routes +++ b/conf/app.routes @@ -130,7 +130,7 @@ POST /cannot-provide-bank-account-details controller GET /choose-account-type controllers.bankdetails.ChooseAccountTypeController.show POST /choose-account-type controllers.bankdetails.ChooseAccountTypeController.submit -## BANK DETAILS (ACCOUNT NAME, NUMBER, SORT CODE) +## ENTER BANK DETAILS GET /account-details controllers.bankdetails.UkBankAccountDetailsController.show POST /account-details controllers.bankdetails.UkBankAccountDetailsController.submit diff --git a/conf/application.conf b/conf/application.conf index 986e61536..bc1f88a86 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -259,6 +259,11 @@ Test { } } +json.encryption { + key = "MTIzNDU2Nzg5MDEyMzQ1Ng==" + previousKeys = [] +} + controllers.internal.RegistrationController = { needsLogging = true needsAuditing = false diff --git a/conf/messages b/conf/messages index dd8a986f1..42b962bfe 100644 --- a/conf/messages +++ b/conf/messages @@ -973,7 +973,6 @@ pages.hasCompanyBankAccount.bullet3 = in the UK pages.hasCompanyBankAccount.bullet4 = able to receive BACS payments validation.hasCompanyBankAccount.missing = Select yes if you are able to provide bank or building society details for the business - # Bank Account Type Page pages.chooseAccountType.heading = What kind of bank or building society account will you use for VAT repayments? pages.chooseAccountType.option.business = Business account @@ -991,22 +990,35 @@ pages.noUKBankAccount.dontWantToProvide = I do not want to provide pages.noUKBankAccount.error = Select why you cannot provide bank account details for the business # Bank Details page -pages.bankDetails.heading = What are the business’s bank or building society account details? -pages.bankDetails.p1 = HMRC VAT will only use this account to send VAT repayments. We will not take money from it. -pages.bankDetails.info = You must tell us if your account details change. -pages.bankDetails.accountName.label = Account name -pages.bankDetails.accountNumber.label = Account number -pages.bankDetails.accountNumber.hint = Must be between 6 and 8 digits long -pages.bankDetails.sortCode.label = Sort code -pages.bankDetails.sortCode.hint = Must be 6 digits long -validation.companyBankAccount.name.missing = Enter the account name -validation.companyBankAccount.name.maxLength = The account name must be 60 characters or less -validation.companyBankAccount.name.invalid = The account name must only include numbers, letters a to z, and special characters such as hyphens, apostrophes and brackets -validation.companyBankAccount.number.missing = Enter the account number -validation.companyBankAccount.number.invalid = Account number must be between 6 and 8 digits -validation.companyBankAccount.sortCode.missing = Enter the account sort code -validation.companyBankAccount.sortCode.invalid = Enter a valid account sort code -validation.companyBankAccount.invalidCombination = Enter a valid bank account number and sort code +pages.bankDetails.heading.old = What are the business’s bank or building society account details? +pages.bankDetails.heading.new = What are the business’s account details? +pages.bankDetails.p1.old = HMRC VAT will only use this account to send VAT repayments. We will not take money from it. +pages.bankDetails.p1.new = HMRC VAT will only use this information to send VAT repayments. Money will not be taken from the account you supply. +pages.bankDetails.info = You must tell us if your account details change. +pages.bankDetails.accountName.label.old = Account name +pages.bankDetails.accountName.label.new = Name on the account +pages.bankDetails.accountName.hint = Enter the name as it appears on bank statements and in your VAT account +pages.bankDetails.accountNumber.label = Account number +pages.bankDetails.accountNumber.hint = Must be between 6 and 8 digits long +pages.bankDetails.sortCode.label = Sort code +pages.bankDetails.sortCode.hint = Must be 6 digits long +pages.bankDetails.rollNumber.label = Building society roll number (if you have one) +pages.bankDetails.rollNumber.hint = You can find it on your card, statement or passbook +validation.companyBankAccount.name.missing.old = Enter the account name +validation.companyBankAccount.name.missing.new = Enter the name on the account +validation.companyBankAccount.name.maxLength = The account name must be 60 characters or less +validation.companyBankAccount.name.invalid.old = The account name must only include numbers, letters a to z, and special characters such as hyphens, apostrophes and brackets +validation.companyBankAccount.name.invalid.new = Name on the account must not use special characters +validation.companyBankAccount.number.missing = Enter the account number +validation.companyBankAccount.number.invalid = Account number must be between 6 and 8 digits +validation.companyBankAccount.number.format = Account number must only contain numbers +validation.companyBankAccount.sortCode.missing.old = Enter the account sort code +validation.companyBankAccount.sortCode.missing.new = Enter the sort code +validation.companyBankAccount.sortCode.invalid = Enter a valid account sort code +validation.companyBankAccount.sortCode.format = Sort code must only contain numbers +validation.companyBankAccount.sortCode.length = Sort code must be 6 digits long +validation.companyBankAccount.invalidCombination = Enter a valid bank account number and sort code +validation.companyBankAccount.rollNumber.invalid = Building society roll number must be shorter # Main Business Activity Page pages.mainBusinessActivity.heading = Which activity is the business’s main source of income? diff --git a/conf/messages.cy b/conf/messages.cy index 421a3e15e..7dfc2d624 100644 --- a/conf/messages.cy +++ b/conf/messages.cy @@ -989,22 +989,35 @@ pages.chooseAccountType.option.business = Cyfrif busnes pages.chooseAccountType.option.personal = Cyfrif personol # Bank Details page -pages.bankDetails.heading = Beth yw manylion cyfrif banc neu gymdeithas adeiladu’r busnes? -pages.bankDetails.p1 = Dim ond i anfon ad-daliadau TAW y bydd tîm TAW CThEM yn defnyddio’r cyfrif hwn. Ni fyddwn yn cymryd arian ohono. +pages.bankDetails.heading.old = Beth yw manylion cyfrif banc neu gymdeithas adeiladu’r busnes? +pages.bankDetails.heading.new = Beth yw manylion cyswllt y busnes? +pages.bankDetails.p1.old = Dim ond i anfon ad-daliadau TAW y bydd tîm TAW CThEM yn defnyddio’r cyfrif hwn. Ni fyddwn yn cymryd arian ohono. +pages.bankDetails.p1.new = Dim ond i anfon ad-daliadau TAW y bydd tîm TAW CThEF yn defnyddio’r cyfrif hwn. Ni fydd arian yn cael ei gymryd o’r cyfrif rydych chi’n ei ddarparu. pages.bankDetails.info = Mae’n rhaid i chi roi gwybod i ni os bydd manylion eich cyfrif yn newid. -pages.bankDetails.accountName.label = Enw’r cyfrif +pages.bankDetails.accountName.label.old = Enw’r cyfrif +pages.bankDetails.accountName.label.new = Yr enw sydd ar y cyfrif +pages.bankDetails.accountName.hint = Nodwch yr enw fel y mae’n ymddangos ar gyfriflenni banc ac yn eich cyfrif TAW pages.bankDetails.accountNumber.label = Rhif y cyfrif pages.bankDetails.accountNumber.hint = Mae’n rhaid iddo fod rhwng 6 ac 8 digid o hyd pages.bankDetails.sortCode.label = Cod didoli pages.bankDetails.sortCode.hint = Mae’n rhaid iddo fod yn 6 digid o hyd -validation.companyBankAccount.name.missing = Nodwch enw’r cyfrif +pages.bankDetails.rollNumber.label = Rhif rôl y gymdeithas adeiladu (os oes gennych un) +pages.bankDetails.rollNumber.hint = Bydd hwn i’w weld ar eich cerdyn, cyfriflen neu baslyfr +validation.companyBankAccount.name.missing.old = Nodwch enw’r cyfrif +validation.companyBankAccount.name.missing.new = Nodwch yr enw sydd ar y cyfrif validation.companyBankAccount.name.maxLength = Mae’n rhaid i enw’r cyfrif fod yn 60 o gymeriadau neu lai -validation.companyBankAccount.name.invalid = Mae’n rhaid i enw’r cyfrif gynnwys rhifau, y llythrennau a i z, a chymeriadau arbennig megis cysylltnodau, collnodau a chromfachau yn unig +validation.companyBankAccount.name.invalid.old = Mae’n rhaid i enw’r cyfrif gynnwys rhifau, y llythrennau a i z, a chymeriadau arbennig megis cysylltnodau, collnodau a chromfachau yn unig +validation.companyBankAccount.name.invalid.new = Enw sydd ar y cyfrif: mae’n rhaid i hyn beidio cynnwys cymeriadau arbennig validation.companyBankAccount.number.missing = Nodwch rif y cyfrif -validation.companyBankAccount.number.invalid = Mae’n rhaid i rif y cyfrif fod rhwng 6 ac 8 digid -validation.companyBankAccount.sortCode.missing = Nodwch god didoli’r cyfrif +validation.companyBankAccount.number.invalid = Rhif y cyfrif: mae’n rhaid i hyn fod rhwng 6 ac 8 digid +validation.companyBankAccount.number.format = Rhif y cyfrif: mae’n rhaid i hyn gynnwys rhifau yn unig +validation.companyBankAccount.sortCode.missing.old = Nodwch god didoli’r cyfrif +validation.companyBankAccount.sortCode.missing.new = Nodwch y cod didoli validation.companyBankAccount.sortCode.invalid = Nodwch god didoli dilys ar gyfer y cyfrif +validation.companyBankAccount.sortCode.length = Cod didoli: mae’n rhaid i hyn fod yn 6 digid +validation.companyBankAccount.sortCode.format = Cod didoli: mae’n rhaid i hyn gynnwys rhifau yn unig validation.companyBankAccount.invalidCombination = Nodwch rif cyfrif banc a chod didoli dilys +validation.companyBankAccount.rollNumber.invalid = Mae’n rhaid i rif rôl y gymdeithas adeiladu fod yn fyrrach # Main Business Activity Page pages.mainBusinessActivity.heading = Pa weithgaredd yw prif ffynhonnell incwm y busnes? diff --git a/it/test/controllers/bankdetails/UKBankAccountDetailsControllerISpec.scala b/it/test/controllers/bankdetails/UKBankAccountDetailsControllerISpec.scala index e90c8a62f..f4364727c 100644 --- a/it/test/controllers/bankdetails/UKBankAccountDetailsControllerISpec.scala +++ b/it/test/controllers/bankdetails/UKBankAccountDetailsControllerISpec.scala @@ -16,10 +16,11 @@ package controllers.bankdetails +import featuretoggle.FeatureSwitch.UseNewBarsVerify import itFixtures.ITRegistrationFixtures import itutil.ControllerISpec -import models.api.EligibilitySubmissionData import models.{BankAccount, TransferOfAGoingConcern} +import models.api.EligibilitySubmissionData import org.jsoup.Jsoup import org.jsoup.nodes.Document import play.api.libs.ws.WSResponse @@ -30,109 +31,232 @@ class UKBankAccountDetailsControllerISpec extends ControllerISpec with ITRegistr val url = "/account-details" - "GET /account-details" must { - "return OK with a blank form if the VAT scheme doesn't contain bank details" in new Setup { - given() - .user.isAuthorised() - .registrationApi.getSection[BankAccount](None) + "GET /account-details" when { - insertCurrentProfileIntoDb(currentProfile, sessionString) + "UseNewBarsVerify is disabled" must { - val res: WSResponse = await(buildClient(url).get()) + "return OK with a blank form if the VAT scheme doesn't contain bank details" in new Setup { + disable(UseNewBarsVerify) + given().user.isAuthorised().registrationApi.getSection[BankAccount](None) - res.status mustBe OK - } - "return OK with a pre-populated form from backend" in new Setup { - given() - .user.isAuthorised() - .registrationApi.getSection[BankAccount](Some(bankAccount)) + insertCurrentProfileIntoDb(currentProfile, sessionString) + + val res: WSResponse = await(buildClient(url).get()) + val doc: Document = Jsoup.parse(res.body) + + res.status mustBe OK + doc.select("input[id=accountName]").size() mustBe 1 + doc.select("input[id=accountNumber]").size() mustBe 1 + doc.select("input[id=sortCode]").size() mustBe 1 + doc.select("input[id=rollNumber]").size() mustBe 0 + } - insertCurrentProfileIntoDb(currentProfile, sessionString) + "return OK with a form pre-populated from the backend when bank details exist" in new Setup { + disable(UseNewBarsVerify) + given().user.isAuthorised().registrationApi.getSection[BankAccount](Some(bankAccount)) - val res: WSResponse = await(buildClient(url).get()) - val doc: Document = Jsoup.parse(res.body) + insertCurrentProfileIntoDb(currentProfile, sessionString) + + val res: WSResponse = await(buildClient(url).get()) + val doc: Document = Jsoup.parse(res.body) - res.status mustBe OK - doc.select("input[id=accountName]").`val`() mustBe testBankName - doc.select("input[id=accountNumber]").`val`() mustBe testAccountNumber - doc.select("input[id=sortCode]").`val`() mustBe testSortCode + res.status mustBe OK + doc.select("input[id=accountName]").`val`() mustBe testBankName + doc.select("input[id=accountNumber]").`val`() mustBe testAccountNumber + doc.select("input[id=sortCode]").`val`() mustBe testSortCode + doc.select("input[id=rollNumber]").size() mustBe 0 + } } - "return OK with a pre-populated form from the backend" in new Setup { - given() - .user.isAuthorised() - .registrationApi.getSection[BankAccount](Some(bankAccount)) - insertCurrentProfileIntoDb(currentProfile, sessionString) + "UseNewBarsVerify is enabled" must { + + "return OK with a blank form when session is empty" in new Setup { + enable(UseNewBarsVerify) + given().user.isAuthorised() - val res: WSResponse = await(buildClient(url).get()) - val doc: Document = Jsoup.parse(res.body) + insertCurrentProfileIntoDb(currentProfile, sessionString) + + val res: WSResponse = await(buildClient(url).get()) + res.status mustBe OK + val doc: Document = Jsoup.parse(res.body) + doc.select("input[id=accountName]").`val`() mustBe "" + } - res.status mustBe OK - doc.select("input[id=accountName]").`val`() mustBe testBankName - doc.select("input[id=accountNumber]").`val`() mustBe testAccountNumber - doc.select("input[id=sortCode]").`val`() mustBe testSortCode + "return OK with a form pre-populated from session when session contains bank details" in new Setup { + enable(UseNewBarsVerify) + given().user.isAuthorised() + + insertCurrentProfileIntoDb(currentProfile, sessionString) + + await( + buildClient(url).post( + Map( + "accountName" -> testBankName, + "accountNumber" -> testAccountNumber, + "sortCode" -> testSortCode, + "rollNumber" -> testRollNumber + ))) + + val res: WSResponse = await(buildClient(url).get()) + val doc: Document = Jsoup.parse(res.body) + + res.status mustBe OK + doc.select("input[id=accountName]").`val`() mustBe testBankName + doc.select("input[id=accountNumber]").`val`() mustBe testAccountNumber + doc.select("input[id=sortCode]").`val`() mustBe testSortCode + doc.select("input[id=rollNumber]").`val`() mustBe testRollNumber + } } } "POST /account-details" when { - "bank details and Bank Account Reputation states are invalid" must { - "return BAD_REQUEST" in new Setup { - given() - .user.isAuthorised() - .bankAccountReputation.fails - .registrationApi.getSection[BankAccount](Some(BankAccount(isProvided = true, None, None))) - .registrationApi.getSection[EligibilitySubmissionData](Some(testEligibilitySubmissionData.copy(registrationReason = TransferOfAGoingConcern))) + + "UseNewBarsVerify is disabled" must { + + "redirect to the Task List when valid bank details" in new Setup { + disable(UseNewBarsVerify) + given().user + .isAuthorised() + .bankAccountReputation + .passes + .registrationApi + .getSection[BankAccount](Some(BankAccount(isProvided = true, None, None))) + .registrationApi + .replaceSection[BankAccount](bankAccount) + .registrationApi + .getSection[EligibilitySubmissionData](Some(testEligibilitySubmissionData.copy(registrationReason = TransferOfAGoingConcern))) insertCurrentProfileIntoDb(currentProfile, sessionString) - val res: WSResponse = await(buildClient(url).post(Map( - "accountName" -> testBankName, - "accountNumber" -> testAccountNumber, - "sortCode" -> testSortCode - ))) + val res: WSResponse = await( + buildClient(url).post( + Map( + "accountName" -> testBankName, + "accountNumber" -> testAccountNumber, + "sortCode" -> testSortCode + ))) + + res.status mustBe SEE_OTHER + res.header(HeaderNames.LOCATION) mustBe Some(controllers.routes.TaskListController.show.url) + } + + "return BAD_REQUEST when valid bank details fail BARS verification" in new Setup { + disable(UseNewBarsVerify) + given().user + .isAuthorised() + .bankAccountReputation + .fails + .registrationApi + .getSection[BankAccount](Some(BankAccount(isProvided = true, None, None))) + .registrationApi + .getSection[EligibilitySubmissionData](Some(testEligibilitySubmissionData.copy(registrationReason = TransferOfAGoingConcern))) + + insertCurrentProfileIntoDb(currentProfile, sessionString) + + val res: WSResponse = await( + buildClient(url).post( + Map( + "accountName" -> testBankName, + "accountNumber" -> testAccountNumber, + "sortCode" -> testSortCode + ))) res.status mustBe BAD_REQUEST } - } - "redirect to the Tasklist if the account details are valid" in new Setup { - given() - .user.isAuthorised() - .bankAccountReputation.passes - .registrationApi.getSection[BankAccount](Some(BankAccount(isProvided = true, None, None))) - .registrationApi.replaceSection[BankAccount](bankAccount) - .registrationApi.getSection[EligibilitySubmissionData](Some(testEligibilitySubmissionData.copy(registrationReason = TransferOfAGoingConcern))) + "return BAD_REQUEST when form fields are empty" in new Setup { + disable(UseNewBarsVerify) + given().user + .isAuthorised() + .registrationApi + .getSection[BankAccount](Some(BankAccount(isProvided = true, None, None))) - insertCurrentProfileIntoDb(currentProfile, sessionString) + insertCurrentProfileIntoDb(currentProfile, sessionString) - val res: WSResponse = await(buildClient(url).post(Map( - "accountName" -> testBankName, - "accountNumber" -> testAccountNumber, - "sortCode" -> testSortCode - ))) + val res: WSResponse = await( + buildClient(url).post( + Map( + "accountName" -> "", + "accountNumber" -> "", + "sortCode" -> "" + ))) - res.status mustBe SEE_OTHER - res.header(HeaderNames.LOCATION) mustBe Some(controllers.routes.TaskListController.show.url) + res.status mustBe BAD_REQUEST + } } - "bank details are incorrect" must { - "return BAD_REQUEST" in new Setup { - given() - .user.isAuthorised() - .bankAccountReputation.fails - .registrationApi.getSection[BankAccount](Some(BankAccount(isProvided = true, None, None))) + "UseNewBarsVerify is enabled" must { + + "save bank details to session and redirect to Task List when form is valid" in new Setup { + enable(UseNewBarsVerify) + given().user.isAuthorised() + + insertCurrentProfileIntoDb(currentProfile, sessionString) + + val res: WSResponse = await( + buildClient(url).post( + Map( + "accountName" -> testBankName, + "accountNumber" -> testAccountNumber, + "sortCode" -> testSortCode + ))) + + res.status mustBe SEE_OTHER + res.header(HeaderNames.LOCATION) mustBe Some(controllers.routes.TaskListController.show.url) + } + + "save bank details including roll number to session and redirect to Task List" in new Setup { + enable(UseNewBarsVerify) + given().user.isAuthorised() + + insertCurrentProfileIntoDb(currentProfile, sessionString) + + val res: WSResponse = await( + buildClient(url).post( + Map( + "accountName" -> testBankName, + "accountNumber" -> testAccountNumber, + "sortCode" -> testSortCode, + "rollNumber" -> testRollNumber + ))) + + res.status mustBe SEE_OTHER + res.header(HeaderNames.LOCATION) mustBe Some(controllers.routes.TaskListController.show.url) + } + + "return BAD_REQUEST without calling BARS when form fields are empty" in new Setup { + enable(UseNewBarsVerify) + given().user.isAuthorised() insertCurrentProfileIntoDb(currentProfile, sessionString) - val res: WSResponse = await(buildClient(url).post(Map( - "accountName" -> "", - "accountNumber" -> "", - "sortCode" -> "" - ))) + val res: WSResponse = await( + buildClient(url).post( + Map( + "accountName" -> "", + "accountNumber" -> "", + "sortCode" -> "" + ))) + + res.status mustBe BAD_REQUEST + } + + "return BAD_REQUEST without calling BARS when account number is invalid" in new Setup { + enable(UseNewBarsVerify) + given().user.isAuthorised() + + insertCurrentProfileIntoDb(currentProfile, sessionString) + + val res: WSResponse = await( + buildClient(url).post( + Map( + "accountName" -> testBankName, + "accountNumber" -> "invalid", + "sortCode" -> testSortCode + ))) res.status mustBe BAD_REQUEST } } } - } diff --git a/it/test/itFixtures/ITRegistrationFixtures.scala b/it/test/itFixtures/ITRegistrationFixtures.scala index 47accec61..6386ce51d 100644 --- a/it/test/itFixtures/ITRegistrationFixtures.scala +++ b/it/test/itFixtures/ITRegistrationFixtures.scala @@ -46,7 +46,8 @@ trait ITRegistrationFixtures extends ApplicantDetailsFixture { val testBankName = "testName" val testSortCode = "123456" val testAccountNumber = "12345678" - val testUkBankDetails: BankAccountDetails = BankAccountDetails(testBankName, testAccountNumber, testSortCode, Some(ValidStatus)) + val testRollNumber = "AB/121212" + val testUkBankDetails: BankAccountDetails = BankAccountDetails(testBankName, testAccountNumber, testSortCode, None, Some(ValidStatus)) val bankAccount: BankAccount = BankAccount(isProvided = true, Some(testUkBankDetails), None) val emptyBankAccount: BankAccount = BankAccount(isProvided = true, None, None) val bankAccountNotProvidedNoReason: BankAccount = BankAccount(isProvided = false, None, None) diff --git a/test/fixtures/VatRegistrationFixture.scala b/test/fixtures/VatRegistrationFixture.scala index 93071d347..fa72960fe 100644 --- a/test/fixtures/VatRegistrationFixture.scala +++ b/test/fixtures/VatRegistrationFixture.scala @@ -397,7 +397,7 @@ trait VatRegistrationFixture extends BaseFixture with FlatRateFixtures with Appl val testBankName = "testName" - val testUkBankDetails: BankAccountDetails = BankAccountDetails(testBankName, testAccountNumber, testSortCode, Some(ValidStatus)) + val testUkBankDetails: BankAccountDetails = BankAccountDetails(testBankName, testAccountNumber, testSortCode, None, Some(ValidStatus)) val bankAccount: BankAccount = BankAccount(isProvided = true, Some(testUkBankDetails), None) diff --git a/test/forms/BankAccountDetailsFormSpec.scala b/test/forms/BankAccountDetailsFormSpec.scala index fb2b8e877..e4bf67750 100644 --- a/test/forms/BankAccountDetailsFormSpec.scala +++ b/test/forms/BankAccountDetailsFormSpec.scala @@ -16,26 +16,27 @@ package forms -import forms.EnterBankAccountDetailsForm._ import models.BankAccountDetails import org.scalatestplus.play.PlaySpec class BankAccountDetailsFormSpec extends PlaySpec { - "EnterBankAccountDetailsForm" should { + val numStr = 60 + val validAccountName = s"${numStr}testAccountName" + val validAccountNumber = "12345678" + val validSortCode = "123456" + val validRollNumber = "AB/121212" - val form = EnterBankAccountDetailsForm.form + "EnterBankAccountDetailsForm (useBarsVerify OFF)" should { + import forms.EnterCompanyBankAccountDetailsForm._ - val numStr = 60 - val validAccountName = s"${numStr}testAccountName" - val validAccountNumber = "12345678" - val validSortCode = "123456" + val form = EnterCompanyBankAccountDetailsForm.form "successfully bind data to the form with no errors and allow the return of a valid BankAccountDetails case class" in { val formData = Map( - ACCOUNT_NAME -> validAccountName, + ACCOUNT_NAME -> validAccountName, ACCOUNT_NUMBER -> validAccountNumber, - SORT_CODE -> validSortCode + SORT_CODE -> validSortCode ) val validBankAccountDetails = BankAccountDetails(validAccountName, validAccountNumber, validSortCode) @@ -46,13 +47,12 @@ class BankAccountDetailsFormSpec extends PlaySpec { "return a FormError when binding an empty account name to the form" in { val formData = Map( - ACCOUNT_NAME -> "", + ACCOUNT_NAME -> "", ACCOUNT_NUMBER -> validAccountNumber, - SORT_CODE -> validSortCode + SORT_CODE -> validSortCode ) val boundForm = form.bind(formData) - boundForm.errors.size mustBe 1 boundForm.errors.head.key mustBe ACCOUNT_NAME boundForm.errors.head.message mustBe accountNameEmptyKey @@ -62,9 +62,9 @@ class BankAccountDetailsFormSpec extends PlaySpec { val exceedMaxLength = "AlPacinoLimitedAlPacinoLimitedAlPacinoLimitedAlPacinoLimitedAlPacinoLimited" val formData = Map( - ACCOUNT_NAME -> exceedMaxLength, + ACCOUNT_NAME -> exceedMaxLength, ACCOUNT_NUMBER -> validAccountNumber, - SORT_CODE -> validSortCode + SORT_CODE -> validSortCode ) val boundForm = form.bind(formData) @@ -78,9 +78,9 @@ class BankAccountDetailsFormSpec extends PlaySpec { val invalidAccountName = "123#@~" val formData = Map( - ACCOUNT_NAME -> invalidAccountName, + ACCOUNT_NAME -> invalidAccountName, ACCOUNT_NUMBER -> validAccountNumber, - SORT_CODE -> validSortCode + SORT_CODE -> validSortCode ) val boundForm = form.bind(formData) @@ -92,9 +92,9 @@ class BankAccountDetailsFormSpec extends PlaySpec { "return a FormError when binding an empty account number to the form" in { val formData = Map( - ACCOUNT_NAME -> validAccountName, + ACCOUNT_NAME -> validAccountName, ACCOUNT_NUMBER -> "", - SORT_CODE -> validSortCode + SORT_CODE -> validSortCode ) val boundForm = form.bind(formData) @@ -108,9 +108,9 @@ class BankAccountDetailsFormSpec extends PlaySpec { val invalidAccountNumber = "ABCDE" val formData = Map( - ACCOUNT_NAME -> validAccountName, + ACCOUNT_NAME -> validAccountName, ACCOUNT_NUMBER -> invalidAccountNumber, - SORT_CODE -> validSortCode + SORT_CODE -> validSortCode ) val boundForm = form.bind(formData) @@ -124,9 +124,9 @@ class BankAccountDetailsFormSpec extends PlaySpec { val invalidAccountNumber = "12345" val formData = Map( - ACCOUNT_NAME -> validAccountName, + ACCOUNT_NAME -> validAccountName, ACCOUNT_NUMBER -> invalidAccountNumber, - SORT_CODE -> validSortCode + SORT_CODE -> validSortCode ) val boundForm = form.bind(formData) @@ -140,9 +140,9 @@ class BankAccountDetailsFormSpec extends PlaySpec { val invalidAccountNumber = "123456789" val formData = Map( - ACCOUNT_NAME -> validAccountName, + ACCOUNT_NAME -> validAccountName, ACCOUNT_NUMBER -> invalidAccountNumber, - SORT_CODE -> validSortCode + SORT_CODE -> validSortCode ) val boundForm = form.bind(formData) @@ -156,9 +156,9 @@ class BankAccountDetailsFormSpec extends PlaySpec { val validAccountNumber = "123 456" val formData = Map( - ACCOUNT_NAME -> validAccountName, + ACCOUNT_NAME -> validAccountName, ACCOUNT_NUMBER -> validAccountNumber, - SORT_CODE -> validSortCode + SORT_CODE -> validSortCode ) val boundForm = form.bind(formData) @@ -166,14 +166,25 @@ class BankAccountDetailsFormSpec extends PlaySpec { boundForm.errors.size mustBe 0 } + "return a FormError when sort code is empty" in { + val formData = Map( + ACCOUNT_NAME -> validAccountName, + ACCOUNT_NUMBER -> validAccountNumber, + SORT_CODE -> "" + ) + val boundForm = form.bind(formData) + boundForm.errors.size mustBe 1 + boundForm.errors.head.key mustBe SORT_CODE + boundForm.errors.head.message mustBe sortCodeEmptyKey + } "return a FormError when binding an invalid sort code part to the form" in { val invalidSortCode = "ABCDEF" val formData = Map( - ACCOUNT_NAME -> validAccountName, + ACCOUNT_NAME -> validAccountName, ACCOUNT_NUMBER -> validAccountNumber, - SORT_CODE -> invalidSortCode + SORT_CODE -> invalidSortCode ) val boundForm = form.bind(formData) @@ -182,34 +193,217 @@ class BankAccountDetailsFormSpec extends PlaySpec { boundForm.errors.head.key mustBe SORT_CODE boundForm.errors.head.message mustBe sortCodeInvalidKey } - "return No FormError when binding a sort code with spaces ( ) part to the form" in { - val invalidSortCode = "02 03 06" + } + + "EnterBankAccountDetailsNewBarsForm (useBarsVerify ON)" should { + import forms.EnterBankAccountDetailsForm._ + val form = EnterBankAccountDetailsForm.form + "successfully bind valid data" in { val formData = Map( - ACCOUNT_NAME -> validAccountName, + ACCOUNT_NAME -> validAccountName, ACCOUNT_NUMBER -> validAccountNumber, - SORT_CODE -> invalidSortCode + SORT_CODE -> validSortCode ) val boundForm = form.bind(formData) + boundForm.get mustBe BankAccountDetails(validAccountName, validAccountNumber, validSortCode) + } - boundForm.errors.size mustBe 0 + "return a FormError when account name is empty" in { + val formData = Map( + ACCOUNT_NAME -> "", + ACCOUNT_NUMBER -> validAccountNumber, + SORT_CODE -> validSortCode + ) + + val boundForm = form.bind(formData) + boundForm.errors.size mustBe 1 + boundForm.errors.head.key mustBe ACCOUNT_NAME + boundForm.errors.head.message mustBe EnterBankAccountDetailsForm.accountNameEmptyKey + } + + "return a FormError when account name exceeds 60 characters" in { + val exceedMaxLength = "AlPacinoLimitedAlPacinoLimitedAlPacinoLimitedAlPacinoLimitedAlPacinoLimited" + + val formData = Map( + ACCOUNT_NAME -> exceedMaxLength, + ACCOUNT_NUMBER -> validAccountNumber, + SORT_CODE -> validSortCode + ) + + val boundForm = form.bind(formData) + boundForm.errors.size mustBe 1 + boundForm.errors.head.key mustBe ACCOUNT_NAME + boundForm.errors.head.message mustBe EnterBankAccountDetailsForm.accountNameMaxLengthKey } - "return a single FormError when the sort code is missing" in { - val emptySortCode = "" + "return a FormError when account name contains invalid characters" in { + val invalidAccountName = "123#@~" val formData = Map( - ACCOUNT_NAME -> validAccountName, + ACCOUNT_NAME -> invalidAccountName, ACCOUNT_NUMBER -> validAccountNumber, - SORT_CODE -> emptySortCode + SORT_CODE -> validSortCode ) val boundForm = form.bind(formData) + boundForm.errors.size mustBe 1 + boundForm.errors.head.key mustBe ACCOUNT_NAME + boundForm.errors.head.message mustBe EnterBankAccountDetailsForm.accountNameInvalidKey + } + "return a FormError when account number is empty" in { + val formData = Map( + ACCOUNT_NAME -> validAccountName, + ACCOUNT_NUMBER -> "", + SORT_CODE -> validSortCode + ) + + val boundForm = form.bind(formData) + boundForm.errors.size mustBe 1 + boundForm.errors.head.key mustBe ACCOUNT_NUMBER + boundForm.errors.head.message mustBe EnterBankAccountDetailsForm.accountNumberEmptyKey + } + + "return a FormError when account number is fewer than 6 digits" in { + val invalidAccountNumber = "12345" + + val formData = Map( + ACCOUNT_NAME -> validAccountName, + ACCOUNT_NUMBER -> invalidAccountNumber, + SORT_CODE -> validSortCode + ) + + val boundForm = form.bind(formData) + boundForm.errors.size mustBe 1 + boundForm.errors.head.key mustBe ACCOUNT_NUMBER + boundForm.errors.head.message mustBe EnterBankAccountDetailsForm.accountNumberInvalidKey + } + + "return a FormError when account number is 8 characters but contains letters" in { + val invalidAccountNumber = "1234567A" + + val formData = Map( + ACCOUNT_NAME -> validAccountName, + ACCOUNT_NUMBER -> invalidAccountNumber, + SORT_CODE -> validSortCode + ) + + val boundForm = form.bind(formData) + boundForm.errors.size mustBe 1 + boundForm.errors.head.key mustBe ACCOUNT_NUMBER + boundForm.errors.head.message mustBe EnterBankAccountDetailsForm.accountNumberFormatKey + } + + "return a FormError when sort code is empty" in { + val formData = Map( + ACCOUNT_NAME -> validAccountName, + ACCOUNT_NUMBER -> validAccountNumber, + SORT_CODE -> "" + ) + + val boundForm = form.bind(formData) boundForm.errors.size mustBe 1 boundForm.errors.head.key mustBe SORT_CODE - boundForm.errors.head.message mustBe sortCodeEmptyKey + boundForm.errors.head.message mustBe EnterBankAccountDetailsForm.sortCodeEmptyKey + } + + "return a FormError when sort code is fewer than 6 digits" in { + val invalidSortCode = "12345" + + val formData = Map( + ACCOUNT_NAME -> validAccountName, + ACCOUNT_NUMBER -> validAccountNumber, + SORT_CODE -> invalidSortCode + ) + + val boundForm = form.bind(formData) + boundForm.errors.size mustBe 1 + boundForm.errors.head.key mustBe SORT_CODE + boundForm.errors.head.message mustBe EnterBankAccountDetailsForm.sortCodeLengthKey + } + + "return a FormError when sort code is 6 characters but contains letters" in { + val invalidSortCode = "ABCDEF" + + val formData = Map( + ACCOUNT_NAME -> validAccountName, + ACCOUNT_NUMBER -> validAccountNumber, + SORT_CODE -> invalidSortCode + ) + + val boundForm = form.bind(formData) + boundForm.errors.size mustBe 1 + boundForm.errors.head.key mustBe SORT_CODE + boundForm.errors.head.message mustBe EnterBankAccountDetailsForm.sortCodeFormatKey + } + + "successfully bind with a valid roll number" in { + val formData = Map( + ACCOUNT_NAME -> validAccountName, + ACCOUNT_NUMBER -> validAccountNumber, + SORT_CODE -> validSortCode, + ROLL_NUMBER -> validRollNumber + ) + + val boundForm = form.bind(formData) + boundForm.errors mustBe empty + boundForm.get.rollNumber mustBe Some(validRollNumber) + } + + "successfully bind when roll number is not provided" in { + val formData = Map( + ACCOUNT_NAME -> validAccountName, + ACCOUNT_NUMBER -> validAccountNumber, + SORT_CODE -> validSortCode + ) + + val boundForm = form.bind(formData) + boundForm.errors mustBe empty + boundForm.get.rollNumber mustBe None + } + + "successfully bind when roll number is an empty string" in { + val formData = Map( + ACCOUNT_NAME -> validAccountName, + ACCOUNT_NUMBER -> validAccountNumber, + SORT_CODE -> validSortCode, + ROLL_NUMBER -> "" + ) + + val boundForm = form.bind(formData) + boundForm.errors mustBe empty + boundForm.get.rollNumber mustBe None + } + + "return a FormError when roll number exceeds 25 characters" in { + val longRollNumber = "A" * 26 + + val formData = Map( + ACCOUNT_NAME -> validAccountName, + ACCOUNT_NUMBER -> validAccountNumber, + SORT_CODE -> validSortCode, + ROLL_NUMBER -> longRollNumber + ) + + val boundForm = form.bind(formData) + boundForm.errors.size mustBe 1 + boundForm.errors.head.key mustBe ROLL_NUMBER + boundForm.errors.head.message mustBe EnterBankAccountDetailsForm.rollNumberInvalidKey + } + + "successfully bind and strip spaces from roll number before storing" in { + val formData = Map( + ACCOUNT_NAME -> validAccountName, + ACCOUNT_NUMBER -> validAccountNumber, + SORT_CODE -> validSortCode, + ROLL_NUMBER -> "AB 121 212" + ) + + val boundForm = form.bind(formData) + boundForm.errors mustBe empty + boundForm.get.rollNumber mustBe Some("AB121212") } } } diff --git a/test/models/BankAccountDetailsSpec.scala b/test/models/BankAccountDetailsSpec.scala index de6bc9bd5..3c3467c8f 100644 --- a/test/models/BankAccountDetailsSpec.scala +++ b/test/models/BankAccountDetailsSpec.scala @@ -16,8 +16,8 @@ package models -import play.api.libs.json.{Json, JsSuccess, JsError} import models.bars.BankAccountType +import play.api.libs.json.{JsError, Json, JsSuccess} import testHelpers.VatRegSpec class BankAccountDetailsSpec extends VatRegSpec { @@ -32,6 +32,11 @@ class BankAccountDetailsSpec extends VatRegSpec { Json.toJson(expected).validate[BankAccount] mustBe JsSuccess(expected) } + "parse successfully from Json when rollNumber is present in details" in { + val expected = validUkBankAccount.copy(details = Some(BankAccountDetails("testName", "12-34-56", "12345678", rollNumber = Some("AB/121212")))) + Json.toJson(expected).validate[BankAccount] mustBe JsSuccess(expected) + } + "parse successfully from Json when neither details or reason are present" in { val expected = validUkBankAccount.copy(details = None, reason = None) Json.toJson(expected).validate[BankAccount] mustBe JsSuccess(expected) @@ -85,5 +90,18 @@ class BankAccountDetailsSpec extends VatRegSpec { ) json.validate[BankAccount].map(_.bankAccountType) mustBe a[JsError] } + + "write rollNumber when present" in { + val json = Json.obj( + "isProvided" -> true, + "details" -> Json.obj( + "name" -> "testName", + "sortCode" -> "12-34-56", + "number" -> "12345678", + "rollNumber" -> "AB/121212" + ) + ) + json.validate[BankAccount].map(_.details.flatMap(_.rollNumber)) mustBe JsSuccess(Some("AB/121212")) + } } } diff --git a/test/models/bars/BankAccountDetailsSessionFormatSpec.scala b/test/models/bars/BankAccountDetailsSessionFormatSpec.scala new file mode 100644 index 000000000..aaa1c80e2 --- /dev/null +++ b/test/models/bars/BankAccountDetailsSessionFormatSpec.scala @@ -0,0 +1,107 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package models.bars + +import models.BankAccountDetails +import models.api.ValidStatus +import org.scalatest.matchers.must.Matchers +import org.scalatest.wordspec.AnyWordSpec +import play.api.libs.json.{Format, Json} +import uk.gov.hmrc.crypto.SymmetricCryptoFactory + +class BankAccountDetailsSessionFormatSpec extends AnyWordSpec with Matchers { + + private val testEncryptionKey = "MTIzNDU2Nzg5MDEyMzQ1Ng==" + private val encrypter = SymmetricCryptoFactory.aesCryptoFromConfig( + baseConfigKey = "json.encryption", + config = com.typesafe.config.ConfigFactory.parseString( + s"""json.encryption.key="$testEncryptionKey"""" + ) + ) + + private implicit val format: Format[BankAccountDetails] = + BankAccountDetailsSessionFormat.format(encrypter) + + val details: BankAccountDetails = BankAccountDetails( + name = "Test Account", + sortCode = "123456", + number = "12345678", + rollNumber = None, + status = None + ) + + "BankAccountDetailsSessionFormat" should { + + "encrypt sortCode and number when writing to Json" in { + val json = Json.toJson(details) + (json \ "sortCode").as[String] mustNot equal("123456") + (json \ "number").as[String] mustNot equal("12345678") + } + + "not encrypt name when writing to Json" in { + val json = Json.toJson(details) + (json \ "name").as[String] mustBe "Test Account" + } + + "not encrypt rollNumber when writing to Json" in { + val withRollNumber = details.copy(rollNumber = Some("AB/121212")) + val json = Json.toJson(withRollNumber) + (json \ "rollNumber").as[String] mustBe "AB/121212" + } + + "rollNumber is not included from Json when None" in { + val json = Json.toJson(details) + (json \ "rollNumber").toOption mustBe None + } + + "status not included from Json when None" in { + val json = Json.toJson(details) + (json \ "status").toOption mustBe None + } + + "read and write BankAccountDetails with all fields" in { + val full = BankAccountDetails( + name = "Test Account", + sortCode = "123456", + number = "12345678", + rollNumber = Some("AB/121212"), + status = Some(ValidStatus) + ) + val json = Json.toJson(full) + val result = Json.fromJson[BankAccountDetails](json).get + result mustBe full + } + + "fail to decrypt with a different key" in { + val json = Json.toJson(details) + + val differentKey = "ZGlmZmVyZW50a2V5MTIzNA==" + val differentEncrypter = SymmetricCryptoFactory.aesCryptoFromConfig( + baseConfigKey = "json.encryption", + config = com.typesafe.config.ConfigFactory.parseString( + s"""json.encryption.key="$differentKey"""" + ) + ) + val differentFormat: Format[BankAccountDetails] = + BankAccountDetailsSessionFormat.format(differentEncrypter) + + intercept[SecurityException] { + Json.fromJson[BankAccountDetails](json)(differentFormat).get + } + } + } +} \ No newline at end of file diff --git a/test/services/BankAccountDetailsServiceSpec.scala b/test/services/BankAccountDetailsServiceSpec.scala index ff1bb4e63..58da16c15 100644 --- a/test/services/BankAccountDetailsServiceSpec.scala +++ b/test/services/BankAccountDetailsServiceSpec.scala @@ -40,14 +40,14 @@ class BankAccountDetailsServiceSpec extends VatSpec with MockRegistrationApiConn implicit val request: Request[_] = FakeRequest() - "fetchBankAccountDetails" should { + "getBankAccountDetails" should { val bankAccount = BankAccount(isProvided = true, Some(BankAccountDetails("testName", "testCode", "testAccNumber")), None) "return a BankAccount" in new Setup { mockGetSection[BankAccount](testRegId, Some(bankAccount)) - val result: Option[BankAccount] = await(service.fetchBankAccountDetails) + val result: Option[BankAccount] = await(service.getBankAccount) result mustBe Some(bankAccount) } @@ -55,19 +55,19 @@ class BankAccountDetailsServiceSpec extends VatSpec with MockRegistrationApiConn "return None if a BankAccount isn't found" in new Setup { mockGetSection[BankAccount](testRegId, None) - val result: Option[BankAccount] = await(service.fetchBankAccountDetails) + val result: Option[BankAccount] = await(service.getBankAccount) result mustBe None } } - "saveBankAccountDetails" should { + "saveBankAccount" should { "return a BankAccount and save to the backend" in new Setup { val fullBankAccount: BankAccount = BankAccount(isProvided = true, Some(BankAccountDetails("testName", "testCode", "testAccNumber")), None) mockReplaceSection[BankAccount](testRegId, fullBankAccount) - val result: BankAccount = await(service.saveBankAccountDetails(fullBankAccount)) + val result: BankAccount = await(service.saveBankAccount(fullBankAccount)) result mustBe fullBankAccount verify(mockRegistrationApiConnector, times(1)) diff --git a/test/views/bankdetails/CompanyBankDetailsViewSpec.scala b/test/views/bankdetails/CompanyBankDetailsViewSpec.scala index 02f2076a7..0d0cf2dec 100644 --- a/test/views/bankdetails/CompanyBankDetailsViewSpec.scala +++ b/test/views/bankdetails/CompanyBankDetailsViewSpec.scala @@ -16,7 +16,7 @@ package views.bankdetails -import forms.EnterBankAccountDetailsForm +import forms.EnterCompanyBankAccountDetailsForm import org.jsoup.Jsoup import org.jsoup.nodes.Document import views.VatRegViewSpec @@ -38,7 +38,7 @@ class CompanyBankDetailsViewSpec extends VatRegViewSpec { val buttonText = "Save and continue" "Company Bank Details Page" should { - implicit lazy val doc: Document = Jsoup.parse(view(EnterBankAccountDetailsForm.form).body) + implicit lazy val doc: Document = Jsoup.parse(view(EnterCompanyBankAccountDetailsForm.form).body) "have the correct title" in new ViewSetup { doc.title must include(title) diff --git a/test/views/bankdetails/EnterBankDetailsViewSpec.scala b/test/views/bankdetails/EnterBankDetailsViewSpec.scala new file mode 100644 index 000000000..8ed0e493e --- /dev/null +++ b/test/views/bankdetails/EnterBankDetailsViewSpec.scala @@ -0,0 +1,97 @@ +/* + * Copyright 2024 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package views.bankdetails + +import featuretoggle.FeatureSwitch.UseNewBarsVerify +import featuretoggle.FeatureToggleSupport._ +import forms.EnterBankAccountDetailsForm +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import views.VatRegViewSpec +import views.html.bankdetails.EnterBankAccountDetails + +class EnterBankDetailsViewSpec extends VatRegViewSpec { + + val view: EnterBankAccountDetails = app.injector.instanceOf[EnterBankAccountDetails] + + val title = "What are the business’s account details?" + val heading = "What are the business’s account details?" + val p1 = "HMRC VAT will only use this information to send VAT repayments. Money will not be taken from the account you supply." + val panelText = "You must tell us if your account details change." + val accountName = "Name on the account" + val accountNumber = "Account number" + val accountNumberHint = "Must be between 6 and 8 digits long" + val sortCode = "Sort code" + val sortCodeHint = "Must be 6 digits long" + val rollNumber = "Building society roll number (if you have one)" + val rollNumberHint = "You can find it on your card, statement or passbook" + val buttonText = "Save and continue" + + implicit lazy val doc: Document = Jsoup.parse(view(EnterBankAccountDetailsForm.form).body) + + "Company Bank Details Page common elements" should { + disable(UseNewBarsVerify) + + "have the correct title" in new ViewSetup { + doc.title must include(title) + } + + "have the correct heading" in new ViewSetup { + doc.heading mustBe Some(heading) + } + + "have the correct p1" in new ViewSetup { + doc.para(1) mustBe Some(p1) + } + + "have the correct panel text" in new ViewSetup { + doc.panelIndent(1) mustBe Some(panelText) + } + + "have the correct Account Name label text" in { + doc.select(Selectors.label).get(0).text mustBe accountName + } + + "have the correct Account Number label text" in { + doc.select(Selectors.label).get(1).text mustBe accountNumber + } + + "have the correct Account Number Hint text" in new ViewSetup { + doc.hintWithMultiple(2) mustBe Some(accountNumberHint) + } + + "have the correct Sort Code label text" in { + doc.select(Selectors.label).get(2).text mustBe sortCode + } + + "have the correct Sort Code Hint text" in new ViewSetup { + doc.hintWithMultiple(3) mustBe Some(sortCodeHint) + } + + "have Roll Number label text" in new ViewSetup { + doc.select(Selectors.label).get(3).text mustBe rollNumber + } + + "have the correct Roll Number Hint text" in new ViewSetup { + doc.hintWithMultiple(4) mustBe Some(rollNumberHint) + } + + "have the correct continue button" in new ViewSetup { + doc.submitButton mustBe Some(buttonText) + } + } +} From c822660f90ae43b1abd30edf9333aff5b31bf43b Mon Sep 17 00:00:00 2001 From: mi-aspectratio <254715997+mi-aspectratio@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:39:19 +0000 Subject: [PATCH 05/27] feat: ADD check bank details controller, views and specs --- .../CheckBankDetailsA11ySpec.scala | 25 +++ .../CheckBankDetailsController.scala | 79 ++++++++ .../UkBankAccountDetailsController.scala | 28 ++- app/services/BankAccountDetailsService.scala | 28 ++- app/services/BarsService.scala | 6 +- .../CheckBankDetailsView.scala.html | 75 ++++++++ conf/app.routes | 4 + conf/messages | 10 + conf/messages.cy | 10 + .../CheckBankDetailsControllerISpec.scala | 180 ++++++++++++++++++ .../UKBankAccountDetailsControllerISpec.scala | 8 +- .../BankAccountDetailsServiceSpec.scala | 12 +- test/services/BarsServiceSpec.scala | 16 +- .../CheckBankDetailsViewSpec.scala | 123 ++++++++++++ 14 files changed, 563 insertions(+), 41 deletions(-) create mode 100644 a11y/pages/bankdetails/CheckBankDetailsA11ySpec.scala create mode 100644 app/controllers/bankdetails/CheckBankDetailsController.scala create mode 100644 app/views/bankdetails/CheckBankDetailsView.scala.html create mode 100644 it/test/controllers/bankdetails/CheckBankDetailsControllerISpec.scala create mode 100644 test/views/bankdetails/CheckBankDetailsViewSpec.scala diff --git a/a11y/pages/bankdetails/CheckBankDetailsA11ySpec.scala b/a11y/pages/bankdetails/CheckBankDetailsA11ySpec.scala new file mode 100644 index 000000000..2bef59292 --- /dev/null +++ b/a11y/pages/bankdetails/CheckBankDetailsA11ySpec.scala @@ -0,0 +1,25 @@ +package pages.bankdetails + +import helpers.A11ySpec +import models.BankAccountDetails +import views.html.bankdetails.CheckBankDetailsView + +class CheckBankDetailsA11ySpec extends A11ySpec { + + val view: CheckBankDetailsView = app.injector.instanceOf[CheckBankDetailsView] + val bankAccountDetails: BankAccountDetails = BankAccountDetails("Test Name", "12345678", "123456", None) + + "the Check Bank Details page" when { + "displaying bank details" must { + "pass all a11y checks" in { + view(bankAccountDetails).body must passAccessibilityChecks + } + } + "displaying bank details with a roll number" must { + "pass all a11y checks" in { + view(bankAccountDetails.copy(rollNumber = Some("AB/121212"))).body must passAccessibilityChecks + } + } + } + +} diff --git a/app/controllers/bankdetails/CheckBankDetailsController.scala b/app/controllers/bankdetails/CheckBankDetailsController.scala new file mode 100644 index 000000000..5ed0f25af --- /dev/null +++ b/app/controllers/bankdetails/CheckBankDetailsController.scala @@ -0,0 +1,79 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers.bankdetails + +import config.{AuthClientConnector, BaseControllerComponents, FrontendAppConfig} +import controllers.BaseController +import featuretoggle.FeatureSwitch.UseNewBarsVerify +import featuretoggle.FeatureToggleSupport.isEnabled +import models.BankAccountDetails +import models.bars.BankAccountDetailsSessionFormat +import play.api.Configuration +import play.api.libs.json.Format +import play.api.mvc.{Action, AnyContent} +import services.{BankAccountDetailsService, SessionService} +import uk.gov.hmrc.crypto.SymmetricCryptoFactory +import views.html.bankdetails.CheckBankDetailsView + +import javax.inject.Inject +import scala.concurrent.{ExecutionContext, Future} + +class CheckBankDetailsController @Inject() ( + val authConnector: AuthClientConnector, + val bankAccountDetailsService: BankAccountDetailsService, + val sessionService: SessionService, + configuration: Configuration, + view: CheckBankDetailsView +)(implicit appConfig: FrontendAppConfig, val executionContext: ExecutionContext, baseControllerComponents: BaseControllerComponents) + extends BaseController { + + private val encrypter = + SymmetricCryptoFactory.aesCryptoFromConfig("json.encryption", configuration.underlying) + + private implicit val encryptedFormat: Format[BankAccountDetails] = + BankAccountDetailsSessionFormat.format(encrypter) + + private val sessionKey = "bankAccountDetails" + + def show: Action[AnyContent] = isAuthenticatedWithProfile { implicit request => implicit profile => + if (isEnabled(UseNewBarsVerify)) { + sessionService.fetchAndGet[BankAccountDetails](sessionKey).map { + case Some(details) => Ok(view(details)) + case None => Redirect(routes.UkBankAccountDetailsController.show) + } + } else { + Future.successful(Redirect(routes.HasBankAccountController.show)) + } + } + + def submit: Action[AnyContent] = isAuthenticatedWithProfile { implicit request => implicit profile => + if (isEnabled(UseNewBarsVerify)) { + sessionService.fetchAndGet[BankAccountDetails](sessionKey).flatMap { + case Some(details) => + bankAccountDetailsService.getBankAccount.flatMap { bankAccount => + bankAccountDetailsService.saveEnteredBankAccountDetails(details, bankAccount.flatMap(_.bankAccountType)).flatMap { + case true => sessionService.remove.map(_ => Redirect(controllers.routes.TaskListController.show.url)) + case false => Future.successful(Redirect(routes.UkBankAccountDetailsController.show)) + } + } + case None => Future.successful(Redirect(routes.UkBankAccountDetailsController.show)) + } + } else { + Future.successful(Redirect(routes.HasBankAccountController.show)) + } + } +} diff --git a/app/controllers/bankdetails/UkBankAccountDetailsController.scala b/app/controllers/bankdetails/UkBankAccountDetailsController.scala index 58bff9740..4be977f9f 100644 --- a/app/controllers/bankdetails/UkBankAccountDetailsController.scala +++ b/app/controllers/bankdetails/UkBankAccountDetailsController.scala @@ -20,9 +20,8 @@ import config.{AuthClientConnector, BaseControllerComponents, FrontendAppConfig} import controllers.BaseController import featuretoggle.FeatureSwitch.UseNewBarsVerify import featuretoggle.FeatureToggleSupport.isEnabled -import forms.EnterCompanyBankAccountDetailsForm +import forms.{EnterBankAccountDetailsForm, EnterCompanyBankAccountDetailsForm} import forms.EnterCompanyBankAccountDetailsForm.{form => enterBankAccountDetailsForm} -import forms.EnterBankAccountDetailsForm import models.BankAccountDetails import models.bars.BankAccountDetailsSessionFormat import play.api.libs.json.Format @@ -30,21 +29,20 @@ import play.api.mvc.{Action, AnyContent} import play.api.Configuration import services.{BankAccountDetailsService, SessionService} import uk.gov.hmrc.crypto.SymmetricCryptoFactory -import views.html.bankdetails.EnterCompanyBankAccountDetails -import views.html.bankdetails.EnterBankAccountDetails +import views.html.bankdetails.{EnterBankAccountDetails, EnterCompanyBankAccountDetails} import javax.inject.Inject import scala.concurrent.{ExecutionContext, Future} class UkBankAccountDetailsController @Inject() ( - val authConnector: AuthClientConnector, - val bankAccountDetailsService: BankAccountDetailsService, - val sessionService: SessionService, - configuration: Configuration, - newBarsView: EnterBankAccountDetails, - oldView: EnterCompanyBankAccountDetails - )(implicit appConfig: FrontendAppConfig, val executionContext: ExecutionContext, baseControllerComponents: BaseControllerComponents) - extends BaseController { + val authConnector: AuthClientConnector, + val bankAccountDetailsService: BankAccountDetailsService, + val sessionService: SessionService, + configuration: Configuration, + newBarsView: EnterBankAccountDetails, + oldView: EnterCompanyBankAccountDetails +)(implicit appConfig: FrontendAppConfig, val executionContext: ExecutionContext, baseControllerComponents: BaseControllerComponents) + extends BaseController { private val encrypter = SymmetricCryptoFactory.aesCryptoFromConfig("json.encryption", configuration.underlying) @@ -78,7 +76,7 @@ class UkBankAccountDetailsController @Inject() ( formWithErrors => Future.successful(BadRequest(newBarsView(formWithErrors))), accountDetails => sessionService.cache[BankAccountDetails](sessionKey, accountDetails).map { _ => - Redirect(controllers.routes.TaskListController.show.url) + Redirect(routes.CheckBankDetailsController.show) } ) } else { @@ -87,11 +85,11 @@ class UkBankAccountDetailsController @Inject() ( .fold( formWithErrors => Future.successful(BadRequest(oldView(formWithErrors))), accountDetails => - bankAccountDetailsService.saveEnteredBankAccountDetails(accountDetails).map { + bankAccountDetailsService.saveEnteredBankAccountDetails(accountDetails, None).map { case true => Redirect(controllers.routes.TaskListController.show.url) case false => BadRequest(oldView(EnterCompanyBankAccountDetailsForm.formWithInvalidAccountReputation.fill(accountDetails))) } ) } } -} \ No newline at end of file +} diff --git a/app/services/BankAccountDetailsService.scala b/app/services/BankAccountDetailsService.scala index 63b0b736e..c82d03f33 100644 --- a/app/services/BankAccountDetailsService.scala +++ b/app/services/BankAccountDetailsService.scala @@ -16,9 +16,12 @@ package services +import config.FrontendAppConfig import connectors.RegistrationApiConnector +import featuretoggle.FeatureSwitch.UseNewBarsVerify +import featuretoggle.FeatureToggleSupport.isEnabled import models._ -import models.api.{IndeterminateStatus, InvalidStatus, ValidStatus} +import models.api.{BankAccountDetailsStatus, IndeterminateStatus, InvalidStatus, ValidStatus} import models.bars.BankAccountType import play.api.mvc.Request import uk.gov.hmrc.http.HeaderCarrier @@ -27,7 +30,9 @@ import javax.inject.{Inject, Singleton} import scala.concurrent.{ExecutionContext, Future} @Singleton -class BankAccountDetailsService @Inject() (val regApiConnector: RegistrationApiConnector, val bankAccountRepService: BankAccountReputationService) { +class BankAccountDetailsService @Inject() (val regApiConnector: RegistrationApiConnector, + bankAccountRepService: BankAccountReputationService, + barsService: BarsService)(implicit appConfig: FrontendAppConfig) { def getBankAccount(implicit hc: HeaderCarrier, profile: CurrentProfile, request: Request[_]): Future[Option[BankAccount]] = regApiConnector.getSection[BankAccount](profile.registrationId) @@ -52,26 +57,35 @@ class BankAccountDetailsService @Inject() (val regApiConnector: RegistrationApiC bankAccount flatMap saveBankAccount } - def saveEnteredBankAccountDetails(accountDetails: BankAccountDetails)(implicit + def saveEnteredBankAccountDetails(bankAccountDetails: BankAccountDetails, bankAccountType: Option[BankAccountType])(implicit hc: HeaderCarrier, profile: CurrentProfile, ex: ExecutionContext, request: Request[_]): Future[Boolean] = for { - existing <- getBankAccount - result <- bankAccountRepService.validateBankDetails(accountDetails).flatMap { + result <- selectBarsEndpoint(bankAccountDetails, bankAccountType).flatMap { case status @ (ValidStatus | IndeterminateStatus) => val bankAccount = BankAccount( isProvided = true, - details = Some(accountDetails.copy(status = Some(status))), + details = Some(bankAccountDetails.copy(status = Some(status))), reason = None, - bankAccountType = existing.flatMap(_.bankAccountType) + bankAccountType = bankAccountType ) saveBankAccount(bankAccount) map (_ => true) case InvalidStatus => Future.successful(false) } } yield result + def selectBarsEndpoint(bankAccountDetails: BankAccountDetails, bankAccountType: Option[BankAccountType])(implicit + hc: HeaderCarrier, + request: Request[_]): Future[BankAccountDetailsStatus] = + if (isEnabled(UseNewBarsVerify)) + barsService.verifyBankDetails( + bankAccountDetails, + bankAccountType.getOrElse(throw new IllegalStateException("bankAccountType is required when UseNewBarsVerify is enabled"))) + else + bankAccountRepService.validateBankDetails(bankAccountDetails) + def saveNoUkBankAccountDetails( reason: NoUKBankAccount)(implicit hc: HeaderCarrier, profile: CurrentProfile, request: Request[_]): Future[BankAccount] = { val bankAccount = BankAccount( diff --git a/app/services/BarsService.scala b/app/services/BarsService.scala index d9be8d76d..8946052ee 100644 --- a/app/services/BarsService.scala +++ b/app/services/BarsService.scala @@ -34,9 +34,9 @@ case class BarsService @Inject() ( )(implicit ec: ExecutionContext) extends Logging { - def verifyBankDetails(bankAccountType: BankAccountType, bankDetails: BankAccountDetails)(implicit + def verifyBankDetails(bankDetails: BankAccountDetails, bankAccountType: BankAccountType)(implicit hc: HeaderCarrier): Future[BankAccountDetailsStatus] = { - val requestBody: JsValue = buildJsonRequestBody(bankAccountType, bankDetails) + val requestBody: JsValue = buildJsonRequestBody(bankDetails, bankAccountType) logger.info(s"Verifying bank details for account type: $bankAccountType") @@ -67,7 +67,7 @@ case class BarsService @Inject() ( case Left(_) => InvalidStatus } - def buildJsonRequestBody(bankAccountType: BankAccountType, bankDetails: BankAccountDetails): JsValue = + def buildJsonRequestBody(bankDetails: BankAccountDetails, bankAccountType: BankAccountType): JsValue = bankAccountType match { case BankAccountType.Personal => Json.toJson( diff --git a/app/views/bankdetails/CheckBankDetailsView.scala.html b/app/views/bankdetails/CheckBankDetailsView.scala.html new file mode 100644 index 000000000..6164e774b --- /dev/null +++ b/app/views/bankdetails/CheckBankDetailsView.scala.html @@ -0,0 +1,75 @@ +@* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + *@ + +@import config.FrontendAppConfig +@import models.BankAccountDetails + +@this( + layout: layouts.layout, + h1: components.h1, + p: components.p, + button: components.button, + formWithCSRF: FormWithCSRF, + govukSummaryList: GovukSummaryList +) + +@(bankDetails: BankAccountDetails)(implicit request: Request[_], messages: Messages, appConfig: FrontendAppConfig) + +@layout(pageTitle = Some(messages("pages.checkBankDetails.heading"))) { + + @h1("pages.checkBankDetails.heading") + + @govukSummaryList(SummaryList( + card = Some(Card( + title = Some(CardTitle(content = Text(messages("pages.checkBankDetails.cardTitle")))), + actions = Some(Actions(items = Seq( + ActionItem( + href = controllers.bankdetails.routes.UkBankAccountDetailsController.show.url, + content = Text(messages("pages.checkBankDetails.change")), + visuallyHiddenText = Some(messages("pages.checkBankDetails.change.hidden")) + ) + ))) + )), + rows = Seq( + Some(SummaryListRow( + key = Key(content = Text(messages("pages.checkBankDetails.accountName")), classes = "govuk-!-width-one-half"), + value = Value(content = Text(bankDetails.name), classes = "govuk-!-width-one-half") + )), + Some(SummaryListRow( + key = Key(content = Text(messages("pages.checkBankDetails.accountNumber")), classes = "govuk-!-width-one-half"), + value = Value(content = Text(bankDetails.number), classes = "govuk-!-width-one-half") + )), + Some(SummaryListRow( + key = Key(content = Text(messages("pages.checkBankDetails.sortCode")), classes = "govuk-!-width-one-half"), + value = Value(content = Text(bankDetails.sortCode), classes = "govuk-!-width-one-half") + )), + bankDetails.rollNumber.map { rollNumber => + SummaryListRow( + key = Key(content = Text(messages("pages.checkBankDetails.rollNumber")), classes = "govuk-!-width-one-half"), + value = Value(content = Text(rollNumber), classes = "govuk-!-width-one-half") + ) + } + ).flatten + )) + + @p { + @messages("pages.checkBankDetails.p1") + } + + @formWithCSRF(action = controllers.bankdetails.routes.CheckBankDetailsController.submit) { + @button("app.common.confirmAndContinue") + } +} \ No newline at end of file diff --git a/conf/app.routes b/conf/app.routes index d66ea6c59..039d9feb2 100644 --- a/conf/app.routes +++ b/conf/app.routes @@ -134,6 +134,10 @@ POST /choose-account-type controllers GET /account-details controllers.bankdetails.UkBankAccountDetailsController.show POST /account-details controllers.bankdetails.UkBankAccountDetailsController.submit +# CHECK BANK DETAILS +GET /check-bank-details controllers.bankdetails.CheckBankDetailsController.show +POST /check-bank-details controllers.bankdetails.CheckBankDetailsController.submit + ## VAT CORRESPONDENCE GET /vat-correspondence-language controllers.business.VatCorrespondenceController.show POST /vat-correspondence-language controllers.business.VatCorrespondenceController.submit diff --git a/conf/messages b/conf/messages index 42b962bfe..5782dcd29 100644 --- a/conf/messages +++ b/conf/messages @@ -1020,6 +1020,16 @@ validation.companyBankAccount.sortCode.length = Sort code must be 6 di validation.companyBankAccount.invalidCombination = Enter a valid bank account number and sort code validation.companyBankAccount.rollNumber.invalid = Building society roll number must be shorter +# Check Bank Details Page +pages.checkBankDetails.heading = Check your account details +pages.checkBankDetails.cardTitle = Bank account details +pages.checkBankDetails.change = Change +pages.checkBankDetails.accountName = Account name +pages.checkBankDetails.accountNumber = Account number +pages.checkBankDetails.sortCode = Sort code +pages.checkBankDetails.rollNumber = Building society roll number +pages.checkBankDetails.p1 = By confirming these account details, you agree the information you have provided is complete and correct. + # Main Business Activity Page pages.mainBusinessActivity.heading = Which activity is the business’s main source of income? validation.mainBusinessActivity.missing = Select the business’s main source of income diff --git a/conf/messages.cy b/conf/messages.cy index 7dfc2d624..7e3712200 100644 --- a/conf/messages.cy +++ b/conf/messages.cy @@ -1019,6 +1019,16 @@ validation.companyBankAccount.sortCode.format = Cod didoli: mae’n rhaid validation.companyBankAccount.invalidCombination = Nodwch rif cyfrif banc a chod didoli dilys validation.companyBankAccount.rollNumber.invalid = Mae’n rhaid i rif rôl y gymdeithas adeiladu fod yn fyrrach +# Check Bank Details Page +pages.checkBankDetails.heading = Gwiriwch fanylion eich cyfrif +pages.checkBankDetails.cardTitle = Manylion cyfrif banc +pages.checkBankDetails.change = Newid +pages.checkBankDetails.accountName = Enw’r cyfrif +pages.checkBankDetails.accountNumber = Rhif y cyfrif +pages.checkBankDetails.sortCode = Cod didoli +pages.checkBankDetails.rollNumber = Rhif rôl y gymdeithas adeiladu +pages.checkBankDetails.p1 = Drwy gadarnhau manylion y cyfrif hwn, rydych yn cytuno bod yr wybodaeth rydych wedi’u darparu yn gyflawn ac yn gywir. + # Main Business Activity Page pages.mainBusinessActivity.heading = Pa weithgaredd yw prif ffynhonnell incwm y busnes? validation.mainBusinessActivity.missing = Dewiswch brif ffynhonnell incwm y busnes diff --git a/it/test/controllers/bankdetails/CheckBankDetailsControllerISpec.scala b/it/test/controllers/bankdetails/CheckBankDetailsControllerISpec.scala new file mode 100644 index 000000000..b9dadc3e6 --- /dev/null +++ b/it/test/controllers/bankdetails/CheckBankDetailsControllerISpec.scala @@ -0,0 +1,180 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers.bankdetails + +import featuretoggle.FeatureSwitch.UseNewBarsVerify +import itFixtures.ITRegistrationFixtures +import itutil.ControllerISpec +import models.BankAccount +import models.api.ValidStatus +import models.bars.BankAccountType +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import play.api.libs.ws.WSResponse +import play.api.test.Helpers._ +import play.mvc.Http.HeaderNames + +class CheckBankDetailsControllerISpec extends ControllerISpec with ITRegistrationFixtures { + + val url = "/check-bank-details" + + "GET /check-bank-details" when { + + "UseNewBarsVerify is disabled" must { + + "redirect to HasBankAccountController" in new Setup { + disable(UseNewBarsVerify) + given().user.isAuthorised() + + insertCurrentProfileIntoDb(currentProfile, sessionString) + + val res: WSResponse = await(buildClient(url).get()) + + res.status mustBe SEE_OTHER + res.header(HeaderNames.LOCATION) mustBe Some(routes.HasBankAccountController.show.url) + } + } + + "UseNewBarsVerify is enabled" must { + + "redirect to UkBankAccountDetailsController when session is empty" in new Setup { + enable(UseNewBarsVerify) + given().user.isAuthorised() + + insertCurrentProfileIntoDb(currentProfile, sessionString) + + val res: WSResponse = await(buildClient(url).get()) + + res.status mustBe SEE_OTHER + res.header(HeaderNames.LOCATION) mustBe Some(routes.UkBankAccountDetailsController.show.url) + } + + "return OK and display bank details when session contains bank details" in new Setup { + enable(UseNewBarsVerify) + given().user.isAuthorised() + + insertCurrentProfileIntoDb(currentProfile, sessionString) + + await( + buildClient("/account-details").post( + Map( + "accountName" -> testBankName, + "accountNumber" -> testAccountNumber, + "sortCode" -> testSortCode, + "rollNumber" -> testRollNumber + ))) + + val res: WSResponse = await(buildClient(url).get()) + val doc: Document = Jsoup.parse(res.body) + + res.status mustBe OK + doc.body().text() must include(testBankName) + doc.body().text() must include(testAccountNumber) + doc.body().text() must include(testSortCode) + doc.body().text() must include(testRollNumber) + } + } + } + + "POST /check-bank-details" when { + + "UseNewBarsVerify is disabled" must { + + "redirect to HasBankAccountController" in new Setup { + disable(UseNewBarsVerify) + given().user.isAuthorised() + + insertCurrentProfileIntoDb(currentProfile, sessionString) + + val res: WSResponse = await(buildClient(url).post(Map.empty[String, String])) + + res.status mustBe SEE_OTHER + res.header(HeaderNames.LOCATION) mustBe Some(routes.HasBankAccountController.show.url) + } + } + + "UseNewBarsVerify is enabled" must { + + "redirect to UkBankAccountDetailsController when session is empty" in new Setup { + enable(UseNewBarsVerify) + given().user.isAuthorised() + + insertCurrentProfileIntoDb(currentProfile, sessionString) + + val res: WSResponse = await(buildClient(url).post(Map.empty[String, String])) + + res.status mustBe SEE_OTHER + res.header(HeaderNames.LOCATION) mustBe Some(routes.UkBankAccountDetailsController.show.url) + } + + "redirect to TaskList and clear session when BARS verification passes" in new Setup { + enable(UseNewBarsVerify) + given().user + .isAuthorised() + .bars + .verifySucceeds(BankAccountType.Business) + .registrationApi + .getSection[BankAccount](Some(BankAccount(isProvided = true, None, None, Some(BankAccountType.Business)))) + .registrationApi + .replaceSection[BankAccount](BankAccount( + isProvided = true, + details = Some(testUkBankDetails), + reason = None, + bankAccountType = Some(BankAccountType.Business) + )) + insertCurrentProfileIntoDb(currentProfile, sessionString) + + await( + buildClient("/account-details").post( + Map( + "accountName" -> testBankName, + "accountNumber" -> testAccountNumber, + "sortCode" -> testSortCode + ))) + + val res: WSResponse = await(buildClient(url).post(Map.empty[String, String])) + + res.status mustBe SEE_OTHER + res.header(HeaderNames.LOCATION) mustBe Some(controllers.routes.TaskListController.show.url) + } + "redirect back to UkBankAccountDetailsController when BARS verification fails" in new Setup { + enable(UseNewBarsVerify) + given().user + .isAuthorised() + .bars + .verifyFails(BankAccountType.Business, BAD_REQUEST) + .registrationApi + .getSection[BankAccount](Some(BankAccount(isProvided = true, None, None, Some(BankAccountType.Business)))) + + insertCurrentProfileIntoDb(currentProfile, sessionString) + + await( + buildClient("/account-details").post( + Map( + "accountName" -> testBankName, + "accountNumber" -> testAccountNumber, + "sortCode" -> testSortCode + ))) + + val res: WSResponse = await(buildClient(url).post(Map.empty[String, String])) + + res.status mustBe SEE_OTHER + res.header(HeaderNames.LOCATION) mustBe Some(routes.UkBankAccountDetailsController.show.url) + } + } + } +} \ No newline at end of file diff --git a/it/test/controllers/bankdetails/UKBankAccountDetailsControllerISpec.scala b/it/test/controllers/bankdetails/UKBankAccountDetailsControllerISpec.scala index f4364727c..08a9b97ac 100644 --- a/it/test/controllers/bankdetails/UKBankAccountDetailsControllerISpec.scala +++ b/it/test/controllers/bankdetails/UKBankAccountDetailsControllerISpec.scala @@ -187,7 +187,7 @@ class UKBankAccountDetailsControllerISpec extends ControllerISpec with ITRegistr "UseNewBarsVerify is enabled" must { - "save bank details to session and redirect to Task List when form is valid" in new Setup { + "save bank details to session and redirect to Check Details Controller when form is valid" in new Setup { enable(UseNewBarsVerify) given().user.isAuthorised() @@ -202,10 +202,10 @@ class UKBankAccountDetailsControllerISpec extends ControllerISpec with ITRegistr ))) res.status mustBe SEE_OTHER - res.header(HeaderNames.LOCATION) mustBe Some(controllers.routes.TaskListController.show.url) + res.header(HeaderNames.LOCATION) mustBe Some(routes.CheckBankDetailsController.show.url) } - "save bank details including roll number to session and redirect to Task List" in new Setup { + "save bank details including roll number to session and redirect to Check Details Controller" in new Setup { enable(UseNewBarsVerify) given().user.isAuthorised() @@ -221,7 +221,7 @@ class UKBankAccountDetailsControllerISpec extends ControllerISpec with ITRegistr ))) res.status mustBe SEE_OTHER - res.header(HeaderNames.LOCATION) mustBe Some(controllers.routes.TaskListController.show.url) + res.header(HeaderNames.LOCATION) mustBe Some(routes.CheckBankDetailsController.show.url) } "return BAD_REQUEST without calling BARS when form fields are empty" in new Setup { diff --git a/test/services/BankAccountDetailsServiceSpec.scala b/test/services/BankAccountDetailsServiceSpec.scala index 58da16c15..5583d3ee6 100644 --- a/test/services/BankAccountDetailsServiceSpec.scala +++ b/test/services/BankAccountDetailsServiceSpec.scala @@ -16,29 +16,33 @@ package services +import config.FrontendAppConfig import connectors.mocks.MockRegistrationApiConnector import models.{BankAccount, BankAccountDetails, BeingSetupOrNameChange} import models.bars.BankAccountType -import models.bars.BankAccountType.Personal import org.mockito.ArgumentMatchers.{any, eq => eqTo} import org.mockito.Mockito._ import org.scalatest.Assertion +import org.scalatestplus.play.guice.GuiceOneAppPerSuite import play.api.mvc.Request import play.api.test.FakeRequest import testHelpers.VatSpec -class BankAccountDetailsServiceSpec extends VatSpec with MockRegistrationApiConnector { +class BankAccountDetailsServiceSpec extends VatSpec with GuiceOneAppPerSuite with MockRegistrationApiConnector { val mockBankAccountRepService: BankAccountReputationService = mock[BankAccountReputationService] + val mockBarsService: BarsService = mock[BarsService] trait Setup { val service: BankAccountDetailsService = new BankAccountDetailsService( mockRegistrationApiConnector, - mockBankAccountRepService + mockBankAccountRepService, + mockBarsService ) } - implicit val request: Request[_] = FakeRequest() + implicit val appConfig: FrontendAppConfig = app.injector.instanceOf[FrontendAppConfig] + implicit val request: Request[_] = FakeRequest() "getBankAccountDetails" should { diff --git a/test/services/BarsServiceSpec.scala b/test/services/BarsServiceSpec.scala index c6cda1b50..a1a8436fc 100644 --- a/test/services/BarsServiceSpec.scala +++ b/test/services/BarsServiceSpec.scala @@ -91,7 +91,7 @@ class BarsServiceSpec extends AnyWordSpec with Matchers with MockitoSugar with S when(mockConnector.verify(any(), any())(any())) .thenReturn(Future.successful(successResponse)) - service.verifyBankDetails(BankAccountType.Personal, personalDetails).futureValue shouldBe ValidStatus + service.verifyBankDetails(personalDetails, BankAccountType.Personal).futureValue shouldBe ValidStatus } } @@ -100,7 +100,7 @@ class BarsServiceSpec extends AnyWordSpec with Matchers with MockitoSugar with S when(mockConnector.verify(any(), any())(any())) .thenReturn(Future.successful(barsResponseWith(accountExists = BarsResponse.Indeterminate))) - service.verifyBankDetails(BankAccountType.Personal, personalDetails).futureValue shouldBe IndeterminateStatus + service.verifyBankDetails(personalDetails, BankAccountType.Personal).futureValue shouldBe IndeterminateStatus } } @@ -109,14 +109,14 @@ class BarsServiceSpec extends AnyWordSpec with Matchers with MockitoSugar with S when(mockConnector.verify(any(), any())(any())) .thenReturn(Future.successful(barsResponseWith(sortCodeIsPresentOnEISCD = BarsResponse.No))) - service.verifyBankDetails(BankAccountType.Personal, personalDetails).futureValue shouldBe InvalidStatus + service.verifyBankDetails(personalDetails, BankAccountType.Personal).futureValue shouldBe InvalidStatus } "the connector throws an exception" in { when(mockConnector.verify(any(), any())(any())) .thenReturn(Future.failed(new RuntimeException("failure"))) - service.verifyBankDetails(BankAccountType.Personal, personalDetails).futureValue shouldBe InvalidStatus + service.verifyBankDetails(personalDetails, BankAccountType.Personal).futureValue shouldBe InvalidStatus } } } @@ -180,7 +180,7 @@ class BarsServiceSpec extends AnyWordSpec with Matchers with MockitoSugar with S "return a JSON body with 'account' and 'subject' keys" when { "given a Personal account type" in { - val result = service.buildJsonRequestBody(BankAccountType.Personal, personalDetails) + val result = service.buildJsonRequestBody(personalDetails, BankAccountType.Personal) result shouldBe Json.toJson( BarsPersonalRequest( @@ -193,14 +193,14 @@ class BarsServiceSpec extends AnyWordSpec with Matchers with MockitoSugar with S "not include a 'business' key" when { "given a Personal account type" in { - val result = service.buildJsonRequestBody(BankAccountType.Personal, personalDetails) + val result = service.buildJsonRequestBody(personalDetails, BankAccountType.Personal) (result \ "business").isDefined shouldBe false } } "return a JSON body with 'account' and 'business' keys" when { "given a Business account type" in { - val result = service.buildJsonRequestBody(BankAccountType.Business, businessDetails) + val result = service.buildJsonRequestBody(businessDetails, BankAccountType.Business) result shouldBe Json.toJson( BarsBusinessRequest( @@ -213,7 +213,7 @@ class BarsServiceSpec extends AnyWordSpec with Matchers with MockitoSugar with S "not include a 'subject' key" when { "given a Business account type" in { - val result = service.buildJsonRequestBody(BankAccountType.Business, businessDetails) + val result = service.buildJsonRequestBody(businessDetails, BankAccountType.Business ) (result \ "subject").isDefined shouldBe false } } diff --git a/test/views/bankdetails/CheckBankDetailsViewSpec.scala b/test/views/bankdetails/CheckBankDetailsViewSpec.scala new file mode 100644 index 000000000..9324190e0 --- /dev/null +++ b/test/views/bankdetails/CheckBankDetailsViewSpec.scala @@ -0,0 +1,123 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package views.bankdetails + +import models.BankAccountDetails +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import views.VatRegViewSpec +import views.html.bankdetails.CheckBankDetailsView + +class CheckBankDetailsViewSpec extends VatRegViewSpec { + + val view: CheckBankDetailsView = app.injector.instanceOf[CheckBankDetailsView] + + val title = "Check your account details" + val heading = "Check your account details" + val cardTitle = "Bank account details" + val changeLink = "Change" + val accountNameLabel = "Account name" + val accountNumberLabel = "Account number" + val sortCodeLabel = "Sort code" + val rollNumberLabel = "Building society roll number" + val p1 = "By confirming these account details, you agree the information you have provided is complete and correct." + val buttonText = "Confirm and continue" + + val bankDetails: BankAccountDetails = BankAccountDetails( + name = "Test Account", + sortCode = "123456", + number = "12345678", + rollNumber = None, + status = None + ) + + val bankDetailsWithRollNumber: BankAccountDetails = bankDetails.copy(rollNumber = Some("AB/121212")) + + "CheckBankDetailsView" should { + + implicit lazy val doc: Document = Jsoup.parse(view(bankDetails).body) + + "have the correct title" in new ViewSetup { + doc.title must include(title) + } + + "have the correct heading" in new ViewSetup { + doc.heading mustBe Some(heading) + } + + "have the correct card title" in new ViewSetup { + doc.select(".govuk-summary-card__title").text mustBe cardTitle + } + + "have a change link" in new ViewSetup { + doc.select(".govuk-summary-card__actions a").text must include(changeLink) + } + + "have the correct account name label" in new ViewSetup { + doc.select(".govuk-summary-list__key").get(0).text mustBe accountNameLabel + } + + "have the correct account name value" in new ViewSetup { + doc.select(".govuk-summary-list__value").get(0).text mustBe "Test Account" + } + + "have the correct account number label" in new ViewSetup { + doc.select(".govuk-summary-list__key").get(1).text mustBe accountNumberLabel + } + + "have the correct account number value" in new ViewSetup { + doc.select(".govuk-summary-list__value").get(1).text mustBe "12345678" + } + + "have the correct sort code label" in new ViewSetup { + doc.select(".govuk-summary-list__key").get(2).text mustBe sortCodeLabel + } + + "have the correct sort code value" in new ViewSetup { + doc.select(".govuk-summary-list__value").get(2).text mustBe "123456" + } + + "not show roll number row when roll number is absent" in new ViewSetup { + doc.select(".govuk-summary-list__key").size mustBe 3 + } + + "have the correct p1" in new ViewSetup { + doc.para(1) mustBe Some(p1) + } + + "have the correct continue button" in new ViewSetup { + doc.submitButton mustBe Some(buttonText) + } + } + + "CheckBankDetailsView with roll number" should { + + implicit lazy val doc: Document = Jsoup.parse(view(bankDetailsWithRollNumber).body) + + "show the roll number row when roll number is present" in new ViewSetup { + doc.select(".govuk-summary-list__key").size mustBe 4 + } + + "have the correct roll number label" in new ViewSetup { + doc.select(".govuk-summary-list__key").get(3).text mustBe rollNumberLabel + } + + "have the correct roll number value" in new ViewSetup { + doc.select(".govuk-summary-list__value").get(3).text mustBe "AB/121212" + } + } +} \ No newline at end of file From 5f9b9e87e18dc7db96c0e33ab74231d28c82d45e Mon Sep 17 00:00:00 2001 From: mi-aspectratio <254715997+mi-aspectratio@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:23:53 +0000 Subject: [PATCH 06/27] feat: add tests for endpoint method --- .../BankAccountDetailsServiceSpec.scala | 82 ++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/test/services/BankAccountDetailsServiceSpec.scala b/test/services/BankAccountDetailsServiceSpec.scala index 5583d3ee6..2e880459c 100644 --- a/test/services/BankAccountDetailsServiceSpec.scala +++ b/test/services/BankAccountDetailsServiceSpec.scala @@ -18,16 +18,21 @@ package services import config.FrontendAppConfig import connectors.mocks.MockRegistrationApiConnector +import featuretoggle.FeatureSwitch.UseNewBarsVerify +import featuretoggle.FeatureToggleSupport.{disable, enable} import models.{BankAccount, BankAccountDetails, BeingSetupOrNameChange} +import models.api.{BankAccountDetailsStatus, IndeterminateStatus, InvalidStatus, ValidStatus} import models.bars.BankAccountType import org.mockito.ArgumentMatchers.{any, eq => eqTo} import org.mockito.Mockito._ -import org.scalatest.Assertion +import org.scalatest.{Assertion, BeforeAndAfterEach} import org.scalatestplus.play.guice.GuiceOneAppPerSuite import play.api.mvc.Request import play.api.test.FakeRequest import testHelpers.VatSpec +import scala.concurrent.Future + class BankAccountDetailsServiceSpec extends VatSpec with GuiceOneAppPerSuite with MockRegistrationApiConnector { val mockBankAccountRepService: BankAccountReputationService = mock[BankAccountReputationService] @@ -133,6 +138,81 @@ class BankAccountDetailsServiceSpec extends VatSpec with GuiceOneAppPerSuite wit } } + "selectBarsEndpoint" should { + + "call barsService when UseNewBarsVerify is enabled" in new Setup { + enable(UseNewBarsVerify) + val bankAccountDetails = BankAccountDetails("testName", "12345678", "123456", None) + val bankAccountType = Some(BankAccountType.Business) + + when(mockBarsService.verifyBankDetails(any(), any())(any())) + .thenReturn(Future.successful(ValidStatus)) + + val result: BankAccountDetailsStatus = await(service.selectBarsEndpoint(bankAccountDetails, bankAccountType)) + + result mustBe ValidStatus + verify(mockBarsService, times(1)).verifyBankDetails(eqTo(bankAccountDetails), eqTo(BankAccountType.Business))(any()) + verifyNoInteractions(mockBankAccountRepService) + reset(mockBarsService) + } + + "call bankAccountRepService when UseNewBarsVerify is disabled" in new Setup { + disable(UseNewBarsVerify) + val bankAccountDetails = BankAccountDetails("testName", "12345678", "123456", None) + + when(mockBankAccountRepService.validateBankDetails(any())(any(), any())) + .thenReturn(Future.successful(ValidStatus)) + + val result: BankAccountDetailsStatus = await(service.selectBarsEndpoint(bankAccountDetails, None)) + + result mustBe ValidStatus + verify(mockBankAccountRepService, times(1)).validateBankDetails(eqTo(bankAccountDetails))(any(), any()) + verifyNoInteractions(mockBarsService) + reset(mockBankAccountRepService) + + } + + "throw IllegalStateException when UseNewBarsVerify is enabled but bankAccountType is None" in new Setup { + enable(UseNewBarsVerify) + val bankAccountDetails = BankAccountDetails("testName", "12345678", "123456", None) + + intercept[IllegalStateException] { + await(service.selectBarsEndpoint(bankAccountDetails, None)) + }.getMessage mustBe "bankAccountType is required when UseNewBarsVerify is enabled" + + verifyNoInteractions(mockBarsService) + verifyNoInteractions(mockBankAccountRepService) + reset(mockBarsService) + } + + "return IndeterminateStatus when UseNewBarsVerify is enabled and BARS returns indeterminate" in new Setup { + enable(UseNewBarsVerify) + val bankAccountDetails = BankAccountDetails("testName", "12345678", "123456", None) + val bankAccountType = Some(BankAccountType.Personal) + + when(mockBarsService.verifyBankDetails(any(), any())(any())) + .thenReturn(Future.successful(IndeterminateStatus)) + + val result: BankAccountDetailsStatus = await(service.selectBarsEndpoint(bankAccountDetails, bankAccountType)) + + result mustBe IndeterminateStatus + reset(mockBarsService) + } + + "return InvalidStatus when UseNewBarsVerify is disabled and bankAccountRepService returns invalid" in new Setup { + disable(UseNewBarsVerify) + val bankAccountDetails = BankAccountDetails("testName", "12345678", "123456", None) + + when(mockBankAccountRepService.validateBankDetails(any())(any(), any())) + .thenReturn(Future.successful(InvalidStatus)) + + val result: BankAccountDetailsStatus = await(service.selectBarsEndpoint(bankAccountDetails, None)) + + result mustBe InvalidStatus + reset(mockBankAccountRepService) + } + } + "saveNoUkBankAccountDetails" should { "save a BankAccount reason and remove bank account details" in new Setup { val existing: BankAccount = BankAccount(isProvided = true, None, None, Some(BankAccountType.Business)) From e243e2f3bd166082483f9c6fb58bfb5f9a03e154 Mon Sep 17 00:00:00 2001 From: mi-aspectratio <254715997+mi-aspectratio@users.noreply.github.com> Date: Sat, 14 Mar 2026 08:36:29 +0000 Subject: [PATCH 07/27] feat: check session for details and account type before starting verification and redicrect to hasbankcontroller + add stub config --- app/config/FrontendAppConfig.scala | 4 +- .../CheckBankDetailsController.scala | 24 ++++--- conf/application.conf | 5 ++ .../CheckBankDetailsControllerISpec.scala | 70 +++++++++++++------ 4 files changed, 68 insertions(+), 35 deletions(-) diff --git a/app/config/FrontendAppConfig.scala b/app/config/FrontendAppConfig.scala index b916881fe..cd7814e3f 100644 --- a/app/config/FrontendAppConfig.scala +++ b/app/config/FrontendAppConfig.scala @@ -104,13 +104,15 @@ class FrontendAppConfig @Inject()(val servicesConfig: ServicesConfig, runModeCon // Bank Account Reputation Section private lazy val bankAccountReputationHost = servicesConfig.baseUrl("bank-account-reputation") + private lazy val bankAccountReputationStubHost = servicesConfig.baseUrl("bank-account-reputation-stub") def validateBankDetailsUrl: String = if (isEnabled(StubBars)) s"$host/register-for-vat/test-only/bars/validate-bank-details" else s"$bankAccountReputationHost/validate/bank-details" def verifyBankDetailsUrl(bankAccountType: BankAccountType): String = - s"$bankAccountReputationHost/verify/${bankAccountType.asBars}" + if (isEnabled(StubBars)) s"$bankAccountReputationStubHost/verify/${bankAccountType.asBars}" + else s"$bankAccountReputationHost/verify/${bankAccountType.asBars}" def fixedEstablishmentUrl: String = s"$eligibilityUrl/fixed-establishment" diff --git a/app/controllers/bankdetails/CheckBankDetailsController.scala b/app/controllers/bankdetails/CheckBankDetailsController.scala index 5ed0f25af..3551eab0d 100644 --- a/app/controllers/bankdetails/CheckBankDetailsController.scala +++ b/app/controllers/bankdetails/CheckBankDetailsController.scala @@ -49,11 +49,11 @@ class CheckBankDetailsController @Inject() ( private val sessionKey = "bankAccountDetails" - def show: Action[AnyContent] = isAuthenticatedWithProfile { implicit request => implicit profile => + def show: Action[AnyContent] = isAuthenticated { implicit request => if (isEnabled(UseNewBarsVerify)) { sessionService.fetchAndGet[BankAccountDetails](sessionKey).map { case Some(details) => Ok(view(details)) - case None => Redirect(routes.UkBankAccountDetailsController.show) + case None => Redirect(routes.HasBankAccountController.show) } } else { Future.successful(Redirect(routes.HasBankAccountController.show)) @@ -62,16 +62,18 @@ class CheckBankDetailsController @Inject() ( def submit: Action[AnyContent] = isAuthenticatedWithProfile { implicit request => implicit profile => if (isEnabled(UseNewBarsVerify)) { - sessionService.fetchAndGet[BankAccountDetails](sessionKey).flatMap { - case Some(details) => - bankAccountDetailsService.getBankAccount.flatMap { bankAccount => - bankAccountDetailsService.saveEnteredBankAccountDetails(details, bankAccount.flatMap(_.bankAccountType)).flatMap { - case true => sessionService.remove.map(_ => Redirect(controllers.routes.TaskListController.show.url)) - case false => Future.successful(Redirect(routes.UkBankAccountDetailsController.show)) + for { + details <- sessionService.fetchAndGet[BankAccountDetails](sessionKey) + bankAccount <- bankAccountDetailsService.getBankAccount + result <- (details, bankAccount.flatMap(_.bankAccountType)) match { + case (Some(accountDetails), Some(accountType)) => + bankAccountDetailsService.saveEnteredBankAccountDetails(accountDetails, Some(accountType)).map { + case true => Redirect(controllers.routes.TaskListController.show.url) + case false => Redirect(routes.UkBankAccountDetailsController.show) } - } - case None => Future.successful(Redirect(routes.UkBankAccountDetailsController.show)) - } + case _ => Future.successful(Redirect(routes.HasBankAccountController.show)) + } + } yield result } else { Future.successful(Redirect(routes.HasBankAccountController.show)) } diff --git a/conf/application.conf b/conf/application.conf index bc1f88a86..27ecc59fe 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -182,6 +182,11 @@ microservice { port = 9871 } + bank-account-reputation-stub { + host = localhost + port = 9871 + } + industry-classification-lookup-frontend { host = localhost port = 9874 diff --git a/it/test/controllers/bankdetails/CheckBankDetailsControllerISpec.scala b/it/test/controllers/bankdetails/CheckBankDetailsControllerISpec.scala index b9dadc3e6..d6e82b50a 100644 --- a/it/test/controllers/bankdetails/CheckBankDetailsControllerISpec.scala +++ b/it/test/controllers/bankdetails/CheckBankDetailsControllerISpec.scala @@ -20,7 +20,6 @@ import featuretoggle.FeatureSwitch.UseNewBarsVerify import itFixtures.ITRegistrationFixtures import itutil.ControllerISpec import models.BankAccount -import models.api.ValidStatus import models.bars.BankAccountType import org.jsoup.Jsoup import org.jsoup.nodes.Document @@ -51,7 +50,7 @@ class CheckBankDetailsControllerISpec extends ControllerISpec with ITRegistratio "UseNewBarsVerify is enabled" must { - "redirect to UkBankAccountDetailsController when session is empty" in new Setup { + "redirect to HasBankAccountController when session is empty" in new Setup { enable(UseNewBarsVerify) given().user.isAuthorised() @@ -60,7 +59,7 @@ class CheckBankDetailsControllerISpec extends ControllerISpec with ITRegistratio val res: WSResponse = await(buildClient(url).get()) res.status mustBe SEE_OTHER - res.header(HeaderNames.LOCATION) mustBe Some(routes.UkBankAccountDetailsController.show.url) + res.header(HeaderNames.LOCATION) mustBe Some(routes.HasBankAccountController.show.url) } "return OK and display bank details when session contains bank details" in new Setup { @@ -109,19 +108,7 @@ class CheckBankDetailsControllerISpec extends ControllerISpec with ITRegistratio "UseNewBarsVerify is enabled" must { - "redirect to UkBankAccountDetailsController when session is empty" in new Setup { - enable(UseNewBarsVerify) - given().user.isAuthorised() - - insertCurrentProfileIntoDb(currentProfile, sessionString) - - val res: WSResponse = await(buildClient(url).post(Map.empty[String, String])) - - res.status mustBe SEE_OTHER - res.header(HeaderNames.LOCATION) mustBe Some(routes.UkBankAccountDetailsController.show.url) - } - - "redirect to TaskList and clear session when BARS verification passes" in new Setup { + "redirect to TaskList when BARS verification passes" in new Setup { enable(UseNewBarsVerify) given().user .isAuthorised() @@ -130,12 +117,13 @@ class CheckBankDetailsControllerISpec extends ControllerISpec with ITRegistratio .registrationApi .getSection[BankAccount](Some(BankAccount(isProvided = true, None, None, Some(BankAccountType.Business)))) .registrationApi - .replaceSection[BankAccount](BankAccount( - isProvided = true, - details = Some(testUkBankDetails), - reason = None, - bankAccountType = Some(BankAccountType.Business) - )) + .replaceSection[BankAccount]( + BankAccount( + isProvided = true, + details = Some(testUkBankDetails), + reason = None, + bankAccountType = Some(BankAccountType.Business) + )) insertCurrentProfileIntoDb(currentProfile, sessionString) await( @@ -151,6 +139,7 @@ class CheckBankDetailsControllerISpec extends ControllerISpec with ITRegistratio res.status mustBe SEE_OTHER res.header(HeaderNames.LOCATION) mustBe Some(controllers.routes.TaskListController.show.url) } + "redirect back to UkBankAccountDetailsController when BARS verification fails" in new Setup { enable(UseNewBarsVerify) given().user @@ -175,6 +164,41 @@ class CheckBankDetailsControllerISpec extends ControllerISpec with ITRegistratio res.status mustBe SEE_OTHER res.header(HeaderNames.LOCATION) mustBe Some(routes.UkBankAccountDetailsController.show.url) } + + "redirect to HasBankAccountController when session is empty" in new Setup { + enable(UseNewBarsVerify) + given().user.isAuthorised() + + insertCurrentProfileIntoDb(currentProfile, sessionString) + + val res: WSResponse = await(buildClient(url).post(Map.empty[String, String])) + + res.status mustBe SEE_OTHER + res.header(HeaderNames.LOCATION) mustBe Some(routes.HasBankAccountController.show.url) + } + + "redirect to HasBankAccountController if there is no account type saved" in new Setup { + enable(UseNewBarsVerify) + given().user + .isAuthorised() + .registrationApi + .getSection[BankAccount](Some(BankAccount(isProvided = true, None, None, bankAccountType = None))) + + insertCurrentProfileIntoDb(currentProfile, sessionString) + + await( + buildClient("/account-details").post( + Map( + "accountName" -> testBankName, + "accountNumber" -> testAccountNumber, + "sortCode" -> testSortCode + ))) + + val res: WSResponse = await(buildClient(url).post(Map.empty[String, String])) + + res.status mustBe SEE_OTHER + res.header(HeaderNames.LOCATION) mustBe Some(routes.HasBankAccountController.show.url) + } } } -} \ No newline at end of file +} From 73e598c7f1ddeb2e66e3df167d0d7f7b64af35c4 Mon Sep 17 00:00:00 2001 From: mi-aspectratio <254715997+mi-aspectratio@users.noreply.github.com> Date: Mon, 16 Mar 2026 06:42:24 +0000 Subject: [PATCH 08/27] feat: modify service logic and tests --- app/services/BankAccountDetailsService.scala | 10 +-- .../BankAccountDetailsServiceSpec.scala | 71 ++++++++++++++----- 2 files changed, 57 insertions(+), 24 deletions(-) diff --git a/app/services/BankAccountDetailsService.scala b/app/services/BankAccountDetailsService.scala index c82d03f33..4105fcdde 100644 --- a/app/services/BankAccountDetailsService.scala +++ b/app/services/BankAccountDetailsService.scala @@ -80,11 +80,11 @@ class BankAccountDetailsService @Inject() (val regApiConnector: RegistrationApiC hc: HeaderCarrier, request: Request[_]): Future[BankAccountDetailsStatus] = if (isEnabled(UseNewBarsVerify)) - barsService.verifyBankDetails( - bankAccountDetails, - bankAccountType.getOrElse(throw new IllegalStateException("bankAccountType is required when UseNewBarsVerify is enabled"))) - else - bankAccountRepService.validateBankDetails(bankAccountDetails) + bankAccountType match { + case Some(accountType) => barsService.verifyBankDetails(bankAccountDetails, accountType) + case None => Future.failed(new IllegalStateException("bankAccountType is required when UseNewBarsVerify is enabled")) + } + else bankAccountRepService.validateBankDetails(bankAccountDetails) def saveNoUkBankAccountDetails( reason: NoUKBankAccount)(implicit hc: HeaderCarrier, profile: CurrentProfile, request: Request[_]): Future[BankAccount] = { diff --git a/test/services/BankAccountDetailsServiceSpec.scala b/test/services/BankAccountDetailsServiceSpec.scala index 2e880459c..00fc15f12 100644 --- a/test/services/BankAccountDetailsServiceSpec.scala +++ b/test/services/BankAccountDetailsServiceSpec.scala @@ -140,7 +140,7 @@ class BankAccountDetailsServiceSpec extends VatSpec with GuiceOneAppPerSuite wit "selectBarsEndpoint" should { - "call barsService when UseNewBarsVerify is enabled" in new Setup { + "call barsService and return ValidStatus when UseNewBarsVerify is enabled" in new Setup { enable(UseNewBarsVerify) val bankAccountDetails = BankAccountDetails("testName", "12345678", "123456", None) val bankAccountType = Some(BankAccountType.Business) @@ -156,23 +156,39 @@ class BankAccountDetailsServiceSpec extends VatSpec with GuiceOneAppPerSuite wit reset(mockBarsService) } - "call bankAccountRepService when UseNewBarsVerify is disabled" in new Setup { - disable(UseNewBarsVerify) + "call barsService and return IndeterminateStatus when UseNewBarsVerify is enabled" in new Setup { + enable(UseNewBarsVerify) val bankAccountDetails = BankAccountDetails("testName", "12345678", "123456", None) + val bankAccountType = Some(BankAccountType.Personal) - when(mockBankAccountRepService.validateBankDetails(any())(any(), any())) - .thenReturn(Future.successful(ValidStatus)) + when(mockBarsService.verifyBankDetails(any(), any())(any())) + .thenReturn(Future.successful(IndeterminateStatus)) - val result: BankAccountDetailsStatus = await(service.selectBarsEndpoint(bankAccountDetails, None)) + val result: BankAccountDetailsStatus = await(service.selectBarsEndpoint(bankAccountDetails, bankAccountType)) - result mustBe ValidStatus - verify(mockBankAccountRepService, times(1)).validateBankDetails(eqTo(bankAccountDetails))(any(), any()) - verifyNoInteractions(mockBarsService) - reset(mockBankAccountRepService) + result mustBe IndeterminateStatus + verify(mockBarsService, times(1)).verifyBankDetails(eqTo(bankAccountDetails), eqTo(BankAccountType.Personal))(any()) + verifyNoInteractions(mockBankAccountRepService) + reset(mockBarsService) + } + + "call barsService and return InvalidStatus when UseNewBarsVerify is enabled" in new Setup { + enable(UseNewBarsVerify) + val bankAccountDetails = BankAccountDetails("testName", "12345678", "123456", None) + val bankAccountType = Some(BankAccountType.Business) + + when(mockBarsService.verifyBankDetails(any(), any())(any())) + .thenReturn(Future.successful(InvalidStatus)) + + val result: BankAccountDetailsStatus = await(service.selectBarsEndpoint(bankAccountDetails, bankAccountType)) + result mustBe InvalidStatus + verify(mockBarsService, times(1)).verifyBankDetails(eqTo(bankAccountDetails), eqTo(BankAccountType.Business))(any()) + verifyNoInteractions(mockBankAccountRepService) + reset(mockBarsService) } - "throw IllegalStateException when UseNewBarsVerify is enabled but bankAccountType is None" in new Setup { + "throw IllegalStateException when UseNewBarsVerify is enabled and bankAccountType is None" in new Setup { enable(UseNewBarsVerify) val bankAccountDetails = BankAccountDetails("testName", "12345678", "123456", None) @@ -185,21 +201,37 @@ class BankAccountDetailsServiceSpec extends VatSpec with GuiceOneAppPerSuite wit reset(mockBarsService) } - "return IndeterminateStatus when UseNewBarsVerify is enabled and BARS returns indeterminate" in new Setup { - enable(UseNewBarsVerify) + "call bankAccountRepService and return ValidStatus when UseNewBarsVerify is disabled" in new Setup { + disable(UseNewBarsVerify) val bankAccountDetails = BankAccountDetails("testName", "12345678", "123456", None) - val bankAccountType = Some(BankAccountType.Personal) - when(mockBarsService.verifyBankDetails(any(), any())(any())) + when(mockBankAccountRepService.validateBankDetails(any())(any(), any())) + .thenReturn(Future.successful(ValidStatus)) + + val result: BankAccountDetailsStatus = await(service.selectBarsEndpoint(bankAccountDetails, None)) + + result mustBe ValidStatus + verify(mockBankAccountRepService, times(1)).validateBankDetails(eqTo(bankAccountDetails))(any(), any()) + verifyNoInteractions(mockBarsService) + reset(mockBankAccountRepService) + } + + "call bankAccountRepService and return IndeterminateStatus when UseNewBarsVerify is disabled" in new Setup { + disable(UseNewBarsVerify) + val bankAccountDetails = BankAccountDetails("testName", "12345678", "123456", None) + + when(mockBankAccountRepService.validateBankDetails(any())(any(), any())) .thenReturn(Future.successful(IndeterminateStatus)) - val result: BankAccountDetailsStatus = await(service.selectBarsEndpoint(bankAccountDetails, bankAccountType)) + val result: BankAccountDetailsStatus = await(service.selectBarsEndpoint(bankAccountDetails, None)) result mustBe IndeterminateStatus - reset(mockBarsService) + verify(mockBankAccountRepService, times(1)).validateBankDetails(eqTo(bankAccountDetails))(any(), any()) + verifyNoInteractions(mockBarsService) + reset(mockBankAccountRepService) } - "return InvalidStatus when UseNewBarsVerify is disabled and bankAccountRepService returns invalid" in new Setup { + "call bankAccountRepService and return InvalidStatus when UseNewBarsVerify is disabled" in new Setup { disable(UseNewBarsVerify) val bankAccountDetails = BankAccountDetails("testName", "12345678", "123456", None) @@ -209,10 +241,11 @@ class BankAccountDetailsServiceSpec extends VatSpec with GuiceOneAppPerSuite wit val result: BankAccountDetailsStatus = await(service.selectBarsEndpoint(bankAccountDetails, None)) result mustBe InvalidStatus + verify(mockBankAccountRepService, times(1)).validateBankDetails(eqTo(bankAccountDetails))(any(), any()) + verifyNoInteractions(mockBarsService) reset(mockBankAccountRepService) } } - "saveNoUkBankAccountDetails" should { "save a BankAccount reason and remove bank account details" in new Setup { val existing: BankAccount = BankAccount(isProvided = true, None, None, Some(BankAccountType.Business)) From 740658f516d6515b901d8209e67fbdb55948bea7 Mon Sep 17 00:00:00 2001 From: HG-Accenture <183600681+HG-Accenture@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:41:26 +0000 Subject: [PATCH 09/27] Lock service --- app/config/FrontendAppConfig.scala | 9 ++ .../AccountDetailsNotVerified.scala | 54 +++++++++ .../UkBankAccountDetailsController.scala | 23 ++-- .../ThirdAttemptLockoutController.scala | 42 +++++++ app/models/Lock.scala | 31 +++++ app/repositories/UserLockRepository.scala | 108 ++++++++++++++++++ app/services/LockService.scala | 62 ++++++++++ .../AccountDetailsNotVerifiedView.scala.html | 57 +++++++++ .../errors/ThirdAttemptLockoutPage.scala.html | 49 ++++++++ conf/messages | 11 ++ .../AccountDetailsNotVerifiedSpec.scala | 87 ++++++++++++++ .../ThirdAttemptLockoutControllerSpec.scala | 53 +++++++++ test/services/LockServiceSpec.scala | 102 +++++++++++++++++ .../HasCompanyBankAccountViewSpec.scala | 16 +-- 14 files changed, 690 insertions(+), 14 deletions(-) create mode 100644 app/controllers/bankdetails/AccountDetailsNotVerified.scala create mode 100644 app/controllers/errors/ThirdAttemptLockoutController.scala create mode 100644 app/models/Lock.scala create mode 100644 app/repositories/UserLockRepository.scala create mode 100644 app/services/LockService.scala create mode 100644 app/views/bankdetails/AccountDetailsNotVerifiedView.scala.html create mode 100644 app/views/errors/ThirdAttemptLockoutPage.scala.html create mode 100644 test/controllers/bankdetails/AccountDetailsNotVerifiedSpec.scala create mode 100644 test/controllers/errors/ThirdAttemptLockoutControllerSpec.scala create mode 100644 test/services/LockServiceSpec.scala diff --git a/app/config/FrontendAppConfig.scala b/app/config/FrontendAppConfig.scala index cd7814e3f..1d1c93196 100644 --- a/app/config/FrontendAppConfig.scala +++ b/app/config/FrontendAppConfig.scala @@ -45,6 +45,13 @@ class FrontendAppConfig @Inject()(val servicesConfig: ServicesConfig, runModeCon lazy val eligibilityQuestionUrl: String = loadConfig("microservice.services.vat-registration-eligibility-frontend.question") implicit val appConfig: FrontendAppConfig = this + lazy val ttlLockSeconds:Int = 86400 + lazy val knownFactsLockAttemptLimit:Int = 3 + lazy val isKnownFactsCheckEnabled:Boolean = true + + + + private lazy val thresholdString: String = runModeConfiguration.get[ConfigList]("vat-threshold").render(ConfigRenderOptions.concise()) lazy val thresholds: Seq[VatThreshold] = Json.parse(thresholdString).as[List[VatThreshold]] @@ -290,6 +297,8 @@ class FrontendAppConfig @Inject()(val servicesConfig: ServicesConfig, runModeCon lazy val govukHowToRegister: String = "https://www.gov.uk/register-for-vat/how-register-for-vat" + lazy val vatTaskList: String = s"$host/register-for-vat/application-progress" + lazy val govukTogcVatNotice: String = "https://www.gov.uk/guidance/transfer-a-business-as-a-going-concern-and-vat-notice-7009" lazy val businessDescriptionMaxLength: Int = servicesConfig.getInt("constants.businessDescriptionMaxLength") diff --git a/app/controllers/bankdetails/AccountDetailsNotVerified.scala b/app/controllers/bankdetails/AccountDetailsNotVerified.scala new file mode 100644 index 000000000..6a6884afb --- /dev/null +++ b/app/controllers/bankdetails/AccountDetailsNotVerified.scala @@ -0,0 +1,54 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers.bankdetails + +import config.{AuthClientConnector, BaseControllerComponents, FrontendAppConfig} +import controllers.BaseController +import play.api.data.Form +import play.api.data.Forms.{boolean, single} +import play.api.mvc.{Action, AnyContent} +import services.{LockService, SessionService} +import views.html.bankdetails.AccountDetailsNotVerifiedView + +import javax.inject.Inject +import scala.concurrent.ExecutionContext + +class AccountDetailsNotVerified @Inject()(val authConnector: AuthClientConnector, + val sessionService: SessionService, + lockService: LockService, + view: AccountDetailsNotVerifiedView) + (implicit appConfig: FrontendAppConfig, + val executionContext: ExecutionContext, + baseControllerComponents: BaseControllerComponents) extends BaseController { + + private val AttemptForm: Form[Boolean] = Form(single("value" -> boolean)) + + def show: Action[AnyContent] = isAuthenticatedWithProfile { + implicit request => implicit profile => + lockService.getBarsAttemptsUsed(profile.registrationId).map { attemptsUsed => + if (attemptsUsed >= appConfig.knownFactsLockAttemptLimit) { + Redirect(controllers.errors.routes.ThirdAttemptLockoutController.show) + } else { + val formWithAttempts = AttemptForm.bind(Map( + "value" -> "true", + "attempts" -> attemptsUsed.toString + )) + Ok(view(formWithAttempts)) + } + } + } +} diff --git a/app/controllers/bankdetails/UkBankAccountDetailsController.scala b/app/controllers/bankdetails/UkBankAccountDetailsController.scala index 4be977f9f..243bb08e2 100644 --- a/app/controllers/bankdetails/UkBankAccountDetailsController.scala +++ b/app/controllers/bankdetails/UkBankAccountDetailsController.scala @@ -27,7 +27,7 @@ import models.bars.BankAccountDetailsSessionFormat import play.api.libs.json.Format import play.api.mvc.{Action, AnyContent} import play.api.Configuration -import services.{BankAccountDetailsService, SessionService} +import services.{BankAccountDetailsService,LockService, SessionService} import uk.gov.hmrc.crypto.SymmetricCryptoFactory import views.html.bankdetails.{EnterBankAccountDetails, EnterCompanyBankAccountDetails} @@ -38,6 +38,7 @@ class UkBankAccountDetailsController @Inject() ( val authConnector: AuthClientConnector, val bankAccountDetailsService: BankAccountDetailsService, val sessionService: SessionService, + val lockService: LockService, configuration: Configuration, newBarsView: EnterBankAccountDetails, oldView: EnterCompanyBankAccountDetails @@ -54,12 +55,19 @@ class UkBankAccountDetailsController @Inject() ( def show: Action[AnyContent] = isAuthenticatedWithProfile { implicit request => implicit profile => if (isEnabled(UseNewBarsVerify)) { - val newBarsForm = EnterBankAccountDetailsForm.form - sessionService.fetchAndGet[BankAccountDetails](sessionKey).map { - case Some(details) => Ok(newBarsView(newBarsForm.fill(details))) - case None => Ok(newBarsView(newBarsForm)) - } - } else { + lockService.getBarsAttemptsUsed(profile.registrationId).map(_ >= appConfig.knownFactsLockAttemptLimit).flatMap { + case true => Future.successful(Redirect(controllers.errors.routes.ThirdAttemptLockoutController.show)) + case false => + val newBarsForm = EnterBankAccountDetailsForm.form + sessionService.fetchAndGet[BankAccountDetails](sessionKey).map { + case Some(details) => Ok(newBarsView(newBarsForm.fill(details))) + case None => Ok(newBarsView(newBarsForm)) + } + } + } + + + else { for { bankDetails <- bankAccountDetailsService.getBankAccount filledForm = bankDetails.flatMap(_.details).fold(enterBankAccountDetailsForm)(enterBankAccountDetailsForm.fill) @@ -92,4 +100,5 @@ class UkBankAccountDetailsController @Inject() ( ) } } + } diff --git a/app/controllers/errors/ThirdAttemptLockoutController.scala b/app/controllers/errors/ThirdAttemptLockoutController.scala new file mode 100644 index 000000000..2255ae189 --- /dev/null +++ b/app/controllers/errors/ThirdAttemptLockoutController.scala @@ -0,0 +1,42 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers.errors + + +import play.api.mvc.{Action, AnyContent, MessagesControllerComponents} +import uk.gov.hmrc.auth.core.{AuthConnector, AuthorisedFunctions} +import config.FrontendAppConfig +import uk.gov.hmrc.play.bootstrap.frontend.controller.FrontendController +import services.SessionService +import views.html.errors.ThirdAttemptLockoutPage + +import javax.inject.{Inject, Singleton} +import scala.concurrent.{ExecutionContext, Future} + +@Singleton +class ThirdAttemptLockoutController @Inject()(mcc: MessagesControllerComponents, + view: ThirdAttemptLockoutPage, + val authConnector: AuthConnector + )(implicit appConfig: FrontendAppConfig, ec: ExecutionContext) extends FrontendController(mcc) with AuthorisedFunctions { + + def show(): Action[AnyContent] = Action.async { + implicit request => + authorised() { + Future.successful(Ok(view())) + } + } +} \ No newline at end of file diff --git a/app/models/Lock.scala b/app/models/Lock.scala new file mode 100644 index 000000000..e6993794c --- /dev/null +++ b/app/models/Lock.scala @@ -0,0 +1,31 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package models + +import play.api.libs.json.{Format, Json, OFormat} +import uk.gov.hmrc.mongo.play.json.formats.MongoJavatimeFormats + +import java.time.Instant + +case class Lock(identifier: String, + failedAttempts: Int, + lastAttemptedAt: Instant) + +object Lock { + implicit val instantFormat: Format[Instant] = MongoJavatimeFormats.instantFormat + implicit lazy val format: OFormat[Lock] = Json.format +} \ No newline at end of file diff --git a/app/repositories/UserLockRepository.scala b/app/repositories/UserLockRepository.scala new file mode 100644 index 000000000..f5942abc2 --- /dev/null +++ b/app/repositories/UserLockRepository.scala @@ -0,0 +1,108 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package repositories + +import org.mongodb.scala.model.Indexes.ascending +import org.mongodb.scala.model._ +import play.api.libs.json._ +import config.FrontendAppConfig +import models.Lock +import models.Lock._ +import uk.gov.hmrc.mongo.MongoComponent +import uk.gov.hmrc.mongo.play.json.PlayMongoRepository + +import java.time.Instant +import java.util.concurrent.TimeUnit +import javax.inject.{Inject, Singleton} +import scala.concurrent.{ExecutionContext, Future} + +@Singleton +class UserLockRepository @Inject()( + mongoComponent: MongoComponent, + appConfig: FrontendAppConfig + )(implicit ec: ExecutionContext) extends PlayMongoRepository[Lock]( + collectionName = "user-lock", + mongoComponent = mongoComponent, + domainFormat = implicitly[Format[Lock]], + indexes = Seq( + IndexModel( + keys = ascending("lastAttemptedAt"), + indexOptions = IndexOptions() + .name("CVEInvalidDataLockExpires") + .expireAfter(appConfig.ttlLockSeconds, TimeUnit.SECONDS) + ), + IndexModel( + keys = ascending("identifier"), + indexOptions = IndexOptions() + .name("IdentifierIdx") + .sparse(true) + .unique(true) + ) + ), + replaceIndexes = true +) { + + def getFailedAttempts(identifier: String): Future[Int] = + collection + .find(Filters.eq("identifier", identifier)) + .headOption() + .map(_.map(_.failedAttempts).getOrElse(0)) + + def isUserLocked(userId: String): Future[Boolean] = { + collection + .find(Filters.in("identifier", userId)) + .toFuture() + .map { _.exists { _.failedAttempts >= appConfig.knownFactsLockAttemptLimit }} + } + + def updateAttempts(userId: String): Future[Map[String, Int]] = { + def updateAttemptsForLockWith(identifier: String): Future[Lock] = { + collection + .find(Filters.eq("identifier", identifier)) + .headOption() + .flatMap { + case Some(existingLock) => + val newLock = existingLock.copy( + failedAttempts = existingLock.failedAttempts + 1, + lastAttemptedAt = Instant.now() + ) + collection.replaceOne( + Filters.and( + Filters.eq("identifier", identifier) + ), + newLock + ) + .toFuture() + .map(_ => newLock) + case _ => + val newLock = Lock(identifier, 1, Instant.now) + collection.insertOne(newLock) + .toFuture() + .map(_ => newLock) + } + } + val updateUserLock = updateAttemptsForLockWith(userId) + + for { + userLock <- updateUserLock + } yield { + Map( + "user" -> userLock.failedAttempts + ) + } + } +} \ No newline at end of file diff --git a/app/services/LockService.scala b/app/services/LockService.scala new file mode 100644 index 000000000..49f67ad24 --- /dev/null +++ b/app/services/LockService.scala @@ -0,0 +1,62 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package services + + +import play.api.mvc.Result +import play.api.mvc.Results.Redirect +import config.FrontendAppConfig +import controllers.errors +import repositories.UserLockRepository +import utils.LoggingUtil + +import javax.inject.{Inject, Singleton} +import scala.concurrent.{ExecutionContext, Future} + +@Singleton +class LockService @Inject()(userLockRepository: UserLockRepository, + config: FrontendAppConfig)(implicit ec: ExecutionContext) extends LoggingUtil{ + + + + def updateAttempts(userId: String): Future[Map[String, Int]] = { + if (config.isKnownFactsCheckEnabled) { + userLockRepository.updateAttempts(userId) + } else { + Future.successful(Map.empty) + } + } + + def isJourneyLocked(userId: String): Future[Boolean] = { + if (config.isKnownFactsCheckEnabled) { + userLockRepository.isUserLocked(userId) + } else { + Future.successful(false) + } + } + + // ---- BARs bank account lock methods ---- + + def getBarsAttemptsUsed(registrationId: String): Future[Int] = + userLockRepository.getFailedAttempts(registrationId) + + def incrementBarsAttempts(registrationId: String): Future[Int] = + userLockRepository.updateAttempts(registrationId).map(_.getOrElse("user", 0)) + + def isBarsLocked(registrationId: String): Future[Boolean] = + userLockRepository.isUserLocked(registrationId) +} diff --git a/app/views/bankdetails/AccountDetailsNotVerifiedView.scala.html b/app/views/bankdetails/AccountDetailsNotVerifiedView.scala.html new file mode 100644 index 000000000..82bebb867 --- /dev/null +++ b/app/views/bankdetails/AccountDetailsNotVerifiedView.scala.html @@ -0,0 +1,57 @@ +@* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + *@ + +@import config.FrontendAppConfig +@import play.api.data.Form +@import play.api.i18n.Messages +@import play.api.mvc.Request +@import uk.gov.hmrc.govukfrontend.views.html.components._ + +@this( + layout: layouts.layout, + h1: components.h1, + p: components.p, + formWithCSRF: FormWithCSRF, + errorSummary: components.errorSummary, +) + +@(form: Form[Boolean])(implicit request: Request[_], messages: Messages, appConfig: FrontendAppConfig) + + +@layout(pageTitle = Some(title(form, messages("pages.accountDetailsCouldNotBeVerified.heading")))){ + + @errorSummary(errors = form.errors) + + @h1("pages.accountDetailsCouldNotBeVerified.heading") + + @p { + @messages("pages.accountDetailsCouldNotBeVerified.para1", 3 - form.data.get("attempts").map(_.toInt).getOrElse(0)) + } + + @p { + @Html(messages("pages.accountDetailsCouldNotBeVerified.para2", appConfig.vatTaskList)) + } +@* + * @p { + * @messages("pages.accountDetailsCouldNotBeVerified.para2") + * } +*@ + + @p { + @messages("pages.accountDetailsCouldNotBeVerified.para3.bold") @messages("pages.accountDetailsCouldNotBeVerified.para3") + } + +} \ No newline at end of file diff --git a/app/views/errors/ThirdAttemptLockoutPage.scala.html b/app/views/errors/ThirdAttemptLockoutPage.scala.html new file mode 100644 index 000000000..d348775a8 --- /dev/null +++ b/app/views/errors/ThirdAttemptLockoutPage.scala.html @@ -0,0 +1,49 @@ +@* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + *@ + +@import config.FrontendAppConfig +@import play.api.i18n.Messages +@import play.api.mvc.Request +@import views.html.components._ +@import views.html.layouts.layout +@import uk.gov.hmrc.govukfrontend.views.html.components._ + + +@this(layout: layout, + h1: h1, + p: p, + govUkHeader: GovukHeader, + govukButton: GovukButton +) + +@()(implicit request: Request[_], messages: Messages, appConfig: FrontendAppConfig) + +@layout(Some(titleNoForm(messages("ThirdAttemptLockoutPage.heading"))), backLink = false) { + + @h1(messages("ThirdAttemptLockoutPage.heading")) + + @p { + @messages("pages.ThirdAttemptLockoutPage.para1") + } + + @p { + @messages("pages.ThirdAttemptLockoutPage.para2") + } + + @p { + @Html(messages("pages.ThirdAttemptLockoutPage.para3", appConfig.vatTaskList)) + } +} \ No newline at end of file diff --git a/conf/messages b/conf/messages index 5782dcd29..c62bcfe0e 100644 --- a/conf/messages +++ b/conf/messages @@ -2050,3 +2050,14 @@ partnerEmail.error.incorrect_format = Enter the email address in the partnerEmail.error.nothing_entered = Enter the email address partnerEmail.error.incorrect_length = The email address must be 132 characters or fewer +#Account Details could not be verified +pages.accountDetailsCouldNotBeVerified.heading = We could not verify the bank details you provided +pages.accountDetailsCouldNotBeVerified.para1 = You have {0} more attempts to provide your account details. +pages.accountDetailsCouldNotBeVerified.para2 = Enter your bank account or building society details again, making sure the name matches exactly as it appears on your account. +pages.accountDetailsCouldNotBeVerified.para3.bold = After 3 consecutive unsuccessful attempts, +pages.accountDetailsCouldNotBeVerified.para3 = you will need to complete your VAT registration before sending us your details. + +ThirdAttemptLockoutPage.heading = Account details could not be verified +pages.ThirdAttemptLockoutPage.para1 = We have been unable to verify the account details you supplied. +pages.ThirdAttemptLockoutPage.para2 = For your security, we have paused this part of the service. +pages.ThirdAttemptLockoutPage.para3 = You can return to the VAT registration task list now, and provide your account details later once your registration is confirmed. \ No newline at end of file diff --git a/test/controllers/bankdetails/AccountDetailsNotVerifiedSpec.scala b/test/controllers/bankdetails/AccountDetailsNotVerifiedSpec.scala new file mode 100644 index 000000000..e58c58d10 --- /dev/null +++ b/test/controllers/bankdetails/AccountDetailsNotVerifiedSpec.scala @@ -0,0 +1,87 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers.bankdetails + +import fixtures.VatRegistrationFixture +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito._ +import play.api.test.FakeRequest +import services.LockService +import testHelpers.{ControllerSpec, FutureAssertions} +import views.html.bankdetails.AccountDetailsNotVerifiedView + +import scala.concurrent.Future + +class AccountDetailsNotVerifiedSpec extends ControllerSpec with VatRegistrationFixture with FutureAssertions { + + val mockLockService: LockService = mock[LockService] + val view: AccountDetailsNotVerifiedView = app.injector.instanceOf[AccountDetailsNotVerifiedView] + + trait Setup { + val testController = new AccountDetailsNotVerified( + mockAuthClientConnector, + mockSessionService, + mockLockService, + view + ) + + mockAuthenticated() + mockWithCurrentProfile(Some(currentProfile)) + } + + "show" should { + "return 200 when attempts used is below the lockout limit" in new Setup { + when(mockLockService.getBarsAttemptsUsed(any())) + .thenReturn(Future.successful(1)) + + callAuthorised(testController.show) { result => + status(result) mustBe OK + } + } + + "return 200 when attempts used is one below the lockout limit" in new Setup { + // knownFactsLockAttemptLimit is 3 in appConfig, so 2 attempts should still show the page + when(mockLockService.getBarsAttemptsUsed(any())) + .thenReturn(Future.successful(2)) + + callAuthorised(testController.show) { result => + status(result) mustBe OK + } + } + + "redirect to ThirdAttemptLockout when attempts used is at or above the lockout limit" in new Setup { + when(mockLockService.getBarsAttemptsUsed(any())) + .thenReturn(Future.successful(3)) // >= appConfig.knownFactsLockAttemptLimit (3) + + callAuthorised(testController.show) { result => + status(result) mustBe SEE_OTHER + redirectLocation(result) mustBe Some(controllers.errors.routes.ThirdAttemptLockoutController.show.url) + } + } + + "redirect to ThirdAttemptLockout when attempts used exceeds the lockout limit" in new Setup { + when(mockLockService.getBarsAttemptsUsed(any())) + .thenReturn(Future.successful(5)) + + callAuthorised(testController.show) { result => + status(result) mustBe SEE_OTHER + redirectLocation(result) mustBe Some(controllers.errors.routes.ThirdAttemptLockoutController.show.url) + } + } + } +} + diff --git a/test/controllers/errors/ThirdAttemptLockoutControllerSpec.scala b/test/controllers/errors/ThirdAttemptLockoutControllerSpec.scala new file mode 100644 index 000000000..1d5ac5965 --- /dev/null +++ b/test/controllers/errors/ThirdAttemptLockoutControllerSpec.scala @@ -0,0 +1,53 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers.errors + +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito._ +import play.api.test.FakeRequest +import testHelpers.{ControllerSpec, FutureAssertions} +import views.html.errors.ThirdAttemptLockoutPage + +import scala.concurrent.Future + +class ThirdAttemptLockoutControllerSpec extends ControllerSpec with FutureAssertions { + + val view: ThirdAttemptLockoutPage = app.injector.instanceOf[ThirdAttemptLockoutPage] + + trait Setup { + val testController = new ThirdAttemptLockoutController( + messagesControllerComponents, + view, + mockAuthConnector + ) + + // ThirdAttemptLockoutController uses AuthorisedFunctions which calls authConnector.authorise + // directly, so we stub mockAuthConnector (uk.gov.hmrc.auth.core.AuthConnector) rather than + // mockAuthClientConnector. + when(mockAuthConnector.authorise[Unit](any(), any())(any(), any())) + .thenReturn(Future.successful(())) + } + + "show" should { + "return 200 and render the lockout page" in new Setup { + val result = testController.show()(FakeRequest()) + status(result) mustBe OK + contentType(result) mustBe Some("text/html") + } + } +} + diff --git a/test/services/LockServiceSpec.scala b/test/services/LockServiceSpec.scala new file mode 100644 index 000000000..bd354b5c9 --- /dev/null +++ b/test/services/LockServiceSpec.scala @@ -0,0 +1,102 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package services + +import config.FrontendAppConfig +import org.mockito.ArgumentMatchers.{any, eq => eqTo} +import org.mockito.Mockito._ +import org.scalatestplus.mockito.MockitoSugar +import org.scalatestplus.play.PlaySpec +import play.api.test.{DefaultAwaitTimeout, FutureAwaits} +import repositories.UserLockRepository + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +class LockServiceSpec extends PlaySpec with MockitoSugar with FutureAwaits with DefaultAwaitTimeout { + + val mockUserLockRepository: UserLockRepository = mock[UserLockRepository] + val mockAppConfig: FrontendAppConfig = mock[FrontendAppConfig] + + val registrationId = "reg-123" + + trait Setup { + val service: LockService = new LockService(mockUserLockRepository, mockAppConfig) + } + + // ---- getBarsAttemptsUsed ---- + + "getBarsAttemptsUsed" should { + "return the number of failed attempts from the repository" in new Setup { + when(mockUserLockRepository.getFailedAttempts(eqTo(registrationId))) + .thenReturn(Future.successful(2)) + + await(service.getBarsAttemptsUsed(registrationId)) mustBe 2 + } + + "return 0 when there are no recorded attempts" in new Setup { + when(mockUserLockRepository.getFailedAttempts(eqTo(registrationId))) + .thenReturn(Future.successful(0)) + + await(service.getBarsAttemptsUsed(registrationId)) mustBe 0 + } + } + + // ---- incrementBarsAttempts ---- + + "incrementBarsAttempts" should { + "return the new total number of failed attempts after incrementing" in new Setup { + when(mockUserLockRepository.updateAttempts(eqTo(registrationId))) + .thenReturn(Future.successful(Map("user" -> 1))) + + await(service.incrementBarsAttempts(registrationId)) mustBe 1 + } + + "return 0 if the repository map does not contain the 'user' key" in new Setup { + when(mockUserLockRepository.updateAttempts(eqTo(registrationId))) + .thenReturn(Future.successful(Map.empty[String, Int])) + + await(service.incrementBarsAttempts(registrationId)) mustBe 0 + } + + "return 3 on the third failed attempt" in new Setup { + when(mockUserLockRepository.updateAttempts(eqTo(registrationId))) + .thenReturn(Future.successful(Map("user" -> 3))) + + await(service.incrementBarsAttempts(registrationId)) mustBe 3 + } + } + + // ---- isBarsLocked ---- + + "isBarsLocked" should { + "return true when the user is locked in the repository" in new Setup { + when(mockUserLockRepository.isUserLocked(eqTo(registrationId))) + .thenReturn(Future.successful(true)) + + await(service.isBarsLocked(registrationId)) mustBe true + } + + "return false when the user is not locked in the repository" in new Setup { + when(mockUserLockRepository.isUserLocked(eqTo(registrationId))) + .thenReturn(Future.successful(false)) + + await(service.isBarsLocked(registrationId)) mustBe false + } + } +} + diff --git a/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala b/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala index a2768174f..02eea18f8 100644 --- a/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala +++ b/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala @@ -27,14 +27,15 @@ class HasCompanyBankAccountViewSpec extends VatRegViewSpec { implicit val doc: Document = Jsoup.parse(view(HasCompanyBankAccountForm.form).body) lazy val view: HasCompanyBankAccountView = app.injector.instanceOf[HasCompanyBankAccountView] - val heading = "Are you able to provide bank or building society account details for the business?" + val heading = "Can you provide bank or building society details for VAT repayments to the business?" val title = s"$heading - Register for VAT - GOV.UK" - val para = "If we owe the business money, we can repay this directly into your bank by BACS. This is faster and more secure than HMRC cheques." - val para2 = "The account does not have to be a dedicated business account but it must be:" - val bullet1 = "separate from a personal account" - val bullet2 = "in the name of the registered person or company" - val bullet3 = "in the UK" - val bullet4 = "able to receive BACS payments" + val para = "If HMRC owes the business money, it will repay this directly to your account." + val para2 = "BACS is normally used for VAT repayments because this is a faster and more secure payment method than a cheque." + val para3 = "The account you select to receive VAT repayments must be:" + val bullet1 = "Used only for this business" + val bullet2 = "In the name of the individual or company registering for VAT" + val bullet3 = "Based in the UK" + val bullet4 = "Able to receive BACS payments" val yes = "Yes" val no = "No" val continue = "Save and continue" @@ -55,6 +56,7 @@ class HasCompanyBankAccountViewSpec extends VatRegViewSpec { "have correct text" in new ViewSetup { doc.para(1) mustBe Some(para) doc.para(2) mustBe Some(para2) + doc.para(3) mustBe Some(para3) } "have correct bullets" in new ViewSetup { From e0b0fa582ac4839b3856d5349a8e09e08ac5e344 Mon Sep 17 00:00:00 2001 From: Hugo Greenwood <183600681+HG-Accenture@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:47:48 +0000 Subject: [PATCH 10/27] Role back headings and paragraphs in bank details view spec --- .../HasCompanyBankAccountViewSpec.scala | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala b/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala index 02eea18f8..a2768174f 100644 --- a/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala +++ b/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala @@ -27,15 +27,14 @@ class HasCompanyBankAccountViewSpec extends VatRegViewSpec { implicit val doc: Document = Jsoup.parse(view(HasCompanyBankAccountForm.form).body) lazy val view: HasCompanyBankAccountView = app.injector.instanceOf[HasCompanyBankAccountView] - val heading = "Can you provide bank or building society details for VAT repayments to the business?" + val heading = "Are you able to provide bank or building society account details for the business?" val title = s"$heading - Register for VAT - GOV.UK" - val para = "If HMRC owes the business money, it will repay this directly to your account." - val para2 = "BACS is normally used for VAT repayments because this is a faster and more secure payment method than a cheque." - val para3 = "The account you select to receive VAT repayments must be:" - val bullet1 = "Used only for this business" - val bullet2 = "In the name of the individual or company registering for VAT" - val bullet3 = "Based in the UK" - val bullet4 = "Able to receive BACS payments" + val para = "If we owe the business money, we can repay this directly into your bank by BACS. This is faster and more secure than HMRC cheques." + val para2 = "The account does not have to be a dedicated business account but it must be:" + val bullet1 = "separate from a personal account" + val bullet2 = "in the name of the registered person or company" + val bullet3 = "in the UK" + val bullet4 = "able to receive BACS payments" val yes = "Yes" val no = "No" val continue = "Save and continue" @@ -56,7 +55,6 @@ class HasCompanyBankAccountViewSpec extends VatRegViewSpec { "have correct text" in new ViewSetup { doc.para(1) mustBe Some(para) doc.para(2) mustBe Some(para2) - doc.para(3) mustBe Some(para3) } "have correct bullets" in new ViewSetup { From a89b9d536aa28cc621276791fcb8be0c15e9f2e1 Mon Sep 17 00:00:00 2001 From: HG-Accenture <183600681+HG-Accenture@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:10:14 +0000 Subject: [PATCH 11/27] Add lock service routes --- conf/app.routes | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/conf/app.routes b/conf/app.routes index 039d9feb2..9bc0cb149 100644 --- a/conf/app.routes +++ b/conf/app.routes @@ -635,3 +635,7 @@ POST /partner/:index/partner-telephone controller ## Partner Email Address Page GET /partner/:index/email-address controllers.partners.PartnerCaptureEmailAddressController.show(index: Int) POST /partner/:index/email-address controllers.partners.PartnerCaptureEmailAddressController.submit(index: Int) + +# Lockout screens +GET /failed-third-attempt controllers.errors.ThirdAttemptLockoutController.show +GET /failed-attempt controllers.bankdetails.AccountDetailsNotVerified.show \ No newline at end of file From 77d94cc4acc744caf40c19bf0b3fb7c739838732 Mon Sep 17 00:00:00 2001 From: HG-Accenture <183600681+HG-Accenture@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:52:25 +0000 Subject: [PATCH 12/27] Rebase --- .../bankdetails/CheckBankDetailsController.scala | 14 ++++++++++++-- .../UkBankAccountDetailsController.scala | 4 ++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/app/controllers/bankdetails/CheckBankDetailsController.scala b/app/controllers/bankdetails/CheckBankDetailsController.scala index 3551eab0d..3a2f118de 100644 --- a/app/controllers/bankdetails/CheckBankDetailsController.scala +++ b/app/controllers/bankdetails/CheckBankDetailsController.scala @@ -25,7 +25,7 @@ import models.bars.BankAccountDetailsSessionFormat import play.api.Configuration import play.api.libs.json.Format import play.api.mvc.{Action, AnyContent} -import services.{BankAccountDetailsService, SessionService} +import services.{BankAccountDetailsService,LockService, SessionService} import uk.gov.hmrc.crypto.SymmetricCryptoFactory import views.html.bankdetails.CheckBankDetailsView @@ -36,6 +36,7 @@ class CheckBankDetailsController @Inject() ( val authConnector: AuthClientConnector, val bankAccountDetailsService: BankAccountDetailsService, val sessionService: SessionService, + val lockService: LockService, configuration: Configuration, view: CheckBankDetailsView )(implicit appConfig: FrontendAppConfig, val executionContext: ExecutionContext, baseControllerComponents: BaseControllerComponents) @@ -69,7 +70,16 @@ class CheckBankDetailsController @Inject() ( case (Some(accountDetails), Some(accountType)) => bankAccountDetailsService.saveEnteredBankAccountDetails(accountDetails, Some(accountType)).map { case true => Redirect(controllers.routes.TaskListController.show.url) - case false => Redirect(routes.UkBankAccountDetailsController.show) + case false => + lockService.incrementBarsAttempts(profile.registrationId).map { attempts => + if (attempts >= appConfig.knownFactsLockAttemptLimit) { + Redirect(controllers.errors.routes.ThirdAttemptLockoutController.show) + } else { + Redirect(controllers.bankdetails.routes.AccountDetailsNotVerified.show) + } + } + Redirect(routes.UkBankAccountDetailsController.show) + } case _ => Future.successful(Redirect(routes.HasBankAccountController.show)) } diff --git a/app/controllers/bankdetails/UkBankAccountDetailsController.scala b/app/controllers/bankdetails/UkBankAccountDetailsController.scala index 243bb08e2..d598ad66a 100644 --- a/app/controllers/bankdetails/UkBankAccountDetailsController.scala +++ b/app/controllers/bankdetails/UkBankAccountDetailsController.scala @@ -61,9 +61,9 @@ class UkBankAccountDetailsController @Inject() ( val newBarsForm = EnterBankAccountDetailsForm.form sessionService.fetchAndGet[BankAccountDetails](sessionKey).map { case Some(details) => Ok(newBarsView(newBarsForm.fill(details))) - case None => Ok(newBarsView(newBarsForm)) + case None => Ok(newBarsView(newBarsForm)) } - } + } } From fc8e7f13ed9a85c33eca9a4c714c7aa0e818f256 Mon Sep 17 00:00:00 2001 From: Muhammad Ibraaheem <254715997+mi-aspectratio@users.noreply.github.com> Date: Mon, 16 Mar 2026 16:54:34 +0000 Subject: [PATCH 13/27] DL-18607 Add Check Bank Account Details Page (#1232) * feat: ADD check bank details controller and views + config to use bars-stubs --- .../CheckBankDetailsA11ySpec.scala | 25 +++ app/config/FrontendAppConfig.scala | 4 +- .../CheckBankDetailsController.scala | 81 +++++++ .../UkBankAccountDetailsController.scala | 28 ++- app/services/BankAccountDetailsService.scala | 28 ++- app/services/BarsService.scala | 6 +- .../CheckBankDetailsView.scala.html | 75 +++++++ conf/app.routes | 4 + conf/application.conf | 5 + conf/messages | 10 + conf/messages.cy | 10 + .../CheckBankDetailsControllerISpec.scala | 204 ++++++++++++++++++ .../UKBankAccountDetailsControllerISpec.scala | 8 +- .../BankAccountDetailsServiceSpec.scala | 127 ++++++++++- test/services/BarsServiceSpec.scala | 16 +- .../CheckBankDetailsViewSpec.scala | 123 +++++++++++ 16 files changed, 711 insertions(+), 43 deletions(-) create mode 100644 a11y/pages/bankdetails/CheckBankDetailsA11ySpec.scala create mode 100644 app/controllers/bankdetails/CheckBankDetailsController.scala create mode 100644 app/views/bankdetails/CheckBankDetailsView.scala.html create mode 100644 it/test/controllers/bankdetails/CheckBankDetailsControllerISpec.scala create mode 100644 test/views/bankdetails/CheckBankDetailsViewSpec.scala diff --git a/a11y/pages/bankdetails/CheckBankDetailsA11ySpec.scala b/a11y/pages/bankdetails/CheckBankDetailsA11ySpec.scala new file mode 100644 index 000000000..2bef59292 --- /dev/null +++ b/a11y/pages/bankdetails/CheckBankDetailsA11ySpec.scala @@ -0,0 +1,25 @@ +package pages.bankdetails + +import helpers.A11ySpec +import models.BankAccountDetails +import views.html.bankdetails.CheckBankDetailsView + +class CheckBankDetailsA11ySpec extends A11ySpec { + + val view: CheckBankDetailsView = app.injector.instanceOf[CheckBankDetailsView] + val bankAccountDetails: BankAccountDetails = BankAccountDetails("Test Name", "12345678", "123456", None) + + "the Check Bank Details page" when { + "displaying bank details" must { + "pass all a11y checks" in { + view(bankAccountDetails).body must passAccessibilityChecks + } + } + "displaying bank details with a roll number" must { + "pass all a11y checks" in { + view(bankAccountDetails.copy(rollNumber = Some("AB/121212"))).body must passAccessibilityChecks + } + } + } + +} diff --git a/app/config/FrontendAppConfig.scala b/app/config/FrontendAppConfig.scala index b916881fe..cd7814e3f 100644 --- a/app/config/FrontendAppConfig.scala +++ b/app/config/FrontendAppConfig.scala @@ -104,13 +104,15 @@ class FrontendAppConfig @Inject()(val servicesConfig: ServicesConfig, runModeCon // Bank Account Reputation Section private lazy val bankAccountReputationHost = servicesConfig.baseUrl("bank-account-reputation") + private lazy val bankAccountReputationStubHost = servicesConfig.baseUrl("bank-account-reputation-stub") def validateBankDetailsUrl: String = if (isEnabled(StubBars)) s"$host/register-for-vat/test-only/bars/validate-bank-details" else s"$bankAccountReputationHost/validate/bank-details" def verifyBankDetailsUrl(bankAccountType: BankAccountType): String = - s"$bankAccountReputationHost/verify/${bankAccountType.asBars}" + if (isEnabled(StubBars)) s"$bankAccountReputationStubHost/verify/${bankAccountType.asBars}" + else s"$bankAccountReputationHost/verify/${bankAccountType.asBars}" def fixedEstablishmentUrl: String = s"$eligibilityUrl/fixed-establishment" diff --git a/app/controllers/bankdetails/CheckBankDetailsController.scala b/app/controllers/bankdetails/CheckBankDetailsController.scala new file mode 100644 index 000000000..3551eab0d --- /dev/null +++ b/app/controllers/bankdetails/CheckBankDetailsController.scala @@ -0,0 +1,81 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers.bankdetails + +import config.{AuthClientConnector, BaseControllerComponents, FrontendAppConfig} +import controllers.BaseController +import featuretoggle.FeatureSwitch.UseNewBarsVerify +import featuretoggle.FeatureToggleSupport.isEnabled +import models.BankAccountDetails +import models.bars.BankAccountDetailsSessionFormat +import play.api.Configuration +import play.api.libs.json.Format +import play.api.mvc.{Action, AnyContent} +import services.{BankAccountDetailsService, SessionService} +import uk.gov.hmrc.crypto.SymmetricCryptoFactory +import views.html.bankdetails.CheckBankDetailsView + +import javax.inject.Inject +import scala.concurrent.{ExecutionContext, Future} + +class CheckBankDetailsController @Inject() ( + val authConnector: AuthClientConnector, + val bankAccountDetailsService: BankAccountDetailsService, + val sessionService: SessionService, + configuration: Configuration, + view: CheckBankDetailsView +)(implicit appConfig: FrontendAppConfig, val executionContext: ExecutionContext, baseControllerComponents: BaseControllerComponents) + extends BaseController { + + private val encrypter = + SymmetricCryptoFactory.aesCryptoFromConfig("json.encryption", configuration.underlying) + + private implicit val encryptedFormat: Format[BankAccountDetails] = + BankAccountDetailsSessionFormat.format(encrypter) + + private val sessionKey = "bankAccountDetails" + + def show: Action[AnyContent] = isAuthenticated { implicit request => + if (isEnabled(UseNewBarsVerify)) { + sessionService.fetchAndGet[BankAccountDetails](sessionKey).map { + case Some(details) => Ok(view(details)) + case None => Redirect(routes.HasBankAccountController.show) + } + } else { + Future.successful(Redirect(routes.HasBankAccountController.show)) + } + } + + def submit: Action[AnyContent] = isAuthenticatedWithProfile { implicit request => implicit profile => + if (isEnabled(UseNewBarsVerify)) { + for { + details <- sessionService.fetchAndGet[BankAccountDetails](sessionKey) + bankAccount <- bankAccountDetailsService.getBankAccount + result <- (details, bankAccount.flatMap(_.bankAccountType)) match { + case (Some(accountDetails), Some(accountType)) => + bankAccountDetailsService.saveEnteredBankAccountDetails(accountDetails, Some(accountType)).map { + case true => Redirect(controllers.routes.TaskListController.show.url) + case false => Redirect(routes.UkBankAccountDetailsController.show) + } + case _ => Future.successful(Redirect(routes.HasBankAccountController.show)) + } + } yield result + } else { + Future.successful(Redirect(routes.HasBankAccountController.show)) + } + } +} diff --git a/app/controllers/bankdetails/UkBankAccountDetailsController.scala b/app/controllers/bankdetails/UkBankAccountDetailsController.scala index 58bff9740..4be977f9f 100644 --- a/app/controllers/bankdetails/UkBankAccountDetailsController.scala +++ b/app/controllers/bankdetails/UkBankAccountDetailsController.scala @@ -20,9 +20,8 @@ import config.{AuthClientConnector, BaseControllerComponents, FrontendAppConfig} import controllers.BaseController import featuretoggle.FeatureSwitch.UseNewBarsVerify import featuretoggle.FeatureToggleSupport.isEnabled -import forms.EnterCompanyBankAccountDetailsForm +import forms.{EnterBankAccountDetailsForm, EnterCompanyBankAccountDetailsForm} import forms.EnterCompanyBankAccountDetailsForm.{form => enterBankAccountDetailsForm} -import forms.EnterBankAccountDetailsForm import models.BankAccountDetails import models.bars.BankAccountDetailsSessionFormat import play.api.libs.json.Format @@ -30,21 +29,20 @@ import play.api.mvc.{Action, AnyContent} import play.api.Configuration import services.{BankAccountDetailsService, SessionService} import uk.gov.hmrc.crypto.SymmetricCryptoFactory -import views.html.bankdetails.EnterCompanyBankAccountDetails -import views.html.bankdetails.EnterBankAccountDetails +import views.html.bankdetails.{EnterBankAccountDetails, EnterCompanyBankAccountDetails} import javax.inject.Inject import scala.concurrent.{ExecutionContext, Future} class UkBankAccountDetailsController @Inject() ( - val authConnector: AuthClientConnector, - val bankAccountDetailsService: BankAccountDetailsService, - val sessionService: SessionService, - configuration: Configuration, - newBarsView: EnterBankAccountDetails, - oldView: EnterCompanyBankAccountDetails - )(implicit appConfig: FrontendAppConfig, val executionContext: ExecutionContext, baseControllerComponents: BaseControllerComponents) - extends BaseController { + val authConnector: AuthClientConnector, + val bankAccountDetailsService: BankAccountDetailsService, + val sessionService: SessionService, + configuration: Configuration, + newBarsView: EnterBankAccountDetails, + oldView: EnterCompanyBankAccountDetails +)(implicit appConfig: FrontendAppConfig, val executionContext: ExecutionContext, baseControllerComponents: BaseControllerComponents) + extends BaseController { private val encrypter = SymmetricCryptoFactory.aesCryptoFromConfig("json.encryption", configuration.underlying) @@ -78,7 +76,7 @@ class UkBankAccountDetailsController @Inject() ( formWithErrors => Future.successful(BadRequest(newBarsView(formWithErrors))), accountDetails => sessionService.cache[BankAccountDetails](sessionKey, accountDetails).map { _ => - Redirect(controllers.routes.TaskListController.show.url) + Redirect(routes.CheckBankDetailsController.show) } ) } else { @@ -87,11 +85,11 @@ class UkBankAccountDetailsController @Inject() ( .fold( formWithErrors => Future.successful(BadRequest(oldView(formWithErrors))), accountDetails => - bankAccountDetailsService.saveEnteredBankAccountDetails(accountDetails).map { + bankAccountDetailsService.saveEnteredBankAccountDetails(accountDetails, None).map { case true => Redirect(controllers.routes.TaskListController.show.url) case false => BadRequest(oldView(EnterCompanyBankAccountDetailsForm.formWithInvalidAccountReputation.fill(accountDetails))) } ) } } -} \ No newline at end of file +} diff --git a/app/services/BankAccountDetailsService.scala b/app/services/BankAccountDetailsService.scala index 63b0b736e..4105fcdde 100644 --- a/app/services/BankAccountDetailsService.scala +++ b/app/services/BankAccountDetailsService.scala @@ -16,9 +16,12 @@ package services +import config.FrontendAppConfig import connectors.RegistrationApiConnector +import featuretoggle.FeatureSwitch.UseNewBarsVerify +import featuretoggle.FeatureToggleSupport.isEnabled import models._ -import models.api.{IndeterminateStatus, InvalidStatus, ValidStatus} +import models.api.{BankAccountDetailsStatus, IndeterminateStatus, InvalidStatus, ValidStatus} import models.bars.BankAccountType import play.api.mvc.Request import uk.gov.hmrc.http.HeaderCarrier @@ -27,7 +30,9 @@ import javax.inject.{Inject, Singleton} import scala.concurrent.{ExecutionContext, Future} @Singleton -class BankAccountDetailsService @Inject() (val regApiConnector: RegistrationApiConnector, val bankAccountRepService: BankAccountReputationService) { +class BankAccountDetailsService @Inject() (val regApiConnector: RegistrationApiConnector, + bankAccountRepService: BankAccountReputationService, + barsService: BarsService)(implicit appConfig: FrontendAppConfig) { def getBankAccount(implicit hc: HeaderCarrier, profile: CurrentProfile, request: Request[_]): Future[Option[BankAccount]] = regApiConnector.getSection[BankAccount](profile.registrationId) @@ -52,26 +57,35 @@ class BankAccountDetailsService @Inject() (val regApiConnector: RegistrationApiC bankAccount flatMap saveBankAccount } - def saveEnteredBankAccountDetails(accountDetails: BankAccountDetails)(implicit + def saveEnteredBankAccountDetails(bankAccountDetails: BankAccountDetails, bankAccountType: Option[BankAccountType])(implicit hc: HeaderCarrier, profile: CurrentProfile, ex: ExecutionContext, request: Request[_]): Future[Boolean] = for { - existing <- getBankAccount - result <- bankAccountRepService.validateBankDetails(accountDetails).flatMap { + result <- selectBarsEndpoint(bankAccountDetails, bankAccountType).flatMap { case status @ (ValidStatus | IndeterminateStatus) => val bankAccount = BankAccount( isProvided = true, - details = Some(accountDetails.copy(status = Some(status))), + details = Some(bankAccountDetails.copy(status = Some(status))), reason = None, - bankAccountType = existing.flatMap(_.bankAccountType) + bankAccountType = bankAccountType ) saveBankAccount(bankAccount) map (_ => true) case InvalidStatus => Future.successful(false) } } yield result + def selectBarsEndpoint(bankAccountDetails: BankAccountDetails, bankAccountType: Option[BankAccountType])(implicit + hc: HeaderCarrier, + request: Request[_]): Future[BankAccountDetailsStatus] = + if (isEnabled(UseNewBarsVerify)) + bankAccountType match { + case Some(accountType) => barsService.verifyBankDetails(bankAccountDetails, accountType) + case None => Future.failed(new IllegalStateException("bankAccountType is required when UseNewBarsVerify is enabled")) + } + else bankAccountRepService.validateBankDetails(bankAccountDetails) + def saveNoUkBankAccountDetails( reason: NoUKBankAccount)(implicit hc: HeaderCarrier, profile: CurrentProfile, request: Request[_]): Future[BankAccount] = { val bankAccount = BankAccount( diff --git a/app/services/BarsService.scala b/app/services/BarsService.scala index d9be8d76d..8946052ee 100644 --- a/app/services/BarsService.scala +++ b/app/services/BarsService.scala @@ -34,9 +34,9 @@ case class BarsService @Inject() ( )(implicit ec: ExecutionContext) extends Logging { - def verifyBankDetails(bankAccountType: BankAccountType, bankDetails: BankAccountDetails)(implicit + def verifyBankDetails(bankDetails: BankAccountDetails, bankAccountType: BankAccountType)(implicit hc: HeaderCarrier): Future[BankAccountDetailsStatus] = { - val requestBody: JsValue = buildJsonRequestBody(bankAccountType, bankDetails) + val requestBody: JsValue = buildJsonRequestBody(bankDetails, bankAccountType) logger.info(s"Verifying bank details for account type: $bankAccountType") @@ -67,7 +67,7 @@ case class BarsService @Inject() ( case Left(_) => InvalidStatus } - def buildJsonRequestBody(bankAccountType: BankAccountType, bankDetails: BankAccountDetails): JsValue = + def buildJsonRequestBody(bankDetails: BankAccountDetails, bankAccountType: BankAccountType): JsValue = bankAccountType match { case BankAccountType.Personal => Json.toJson( diff --git a/app/views/bankdetails/CheckBankDetailsView.scala.html b/app/views/bankdetails/CheckBankDetailsView.scala.html new file mode 100644 index 000000000..6164e774b --- /dev/null +++ b/app/views/bankdetails/CheckBankDetailsView.scala.html @@ -0,0 +1,75 @@ +@* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + *@ + +@import config.FrontendAppConfig +@import models.BankAccountDetails + +@this( + layout: layouts.layout, + h1: components.h1, + p: components.p, + button: components.button, + formWithCSRF: FormWithCSRF, + govukSummaryList: GovukSummaryList +) + +@(bankDetails: BankAccountDetails)(implicit request: Request[_], messages: Messages, appConfig: FrontendAppConfig) + +@layout(pageTitle = Some(messages("pages.checkBankDetails.heading"))) { + + @h1("pages.checkBankDetails.heading") + + @govukSummaryList(SummaryList( + card = Some(Card( + title = Some(CardTitle(content = Text(messages("pages.checkBankDetails.cardTitle")))), + actions = Some(Actions(items = Seq( + ActionItem( + href = controllers.bankdetails.routes.UkBankAccountDetailsController.show.url, + content = Text(messages("pages.checkBankDetails.change")), + visuallyHiddenText = Some(messages("pages.checkBankDetails.change.hidden")) + ) + ))) + )), + rows = Seq( + Some(SummaryListRow( + key = Key(content = Text(messages("pages.checkBankDetails.accountName")), classes = "govuk-!-width-one-half"), + value = Value(content = Text(bankDetails.name), classes = "govuk-!-width-one-half") + )), + Some(SummaryListRow( + key = Key(content = Text(messages("pages.checkBankDetails.accountNumber")), classes = "govuk-!-width-one-half"), + value = Value(content = Text(bankDetails.number), classes = "govuk-!-width-one-half") + )), + Some(SummaryListRow( + key = Key(content = Text(messages("pages.checkBankDetails.sortCode")), classes = "govuk-!-width-one-half"), + value = Value(content = Text(bankDetails.sortCode), classes = "govuk-!-width-one-half") + )), + bankDetails.rollNumber.map { rollNumber => + SummaryListRow( + key = Key(content = Text(messages("pages.checkBankDetails.rollNumber")), classes = "govuk-!-width-one-half"), + value = Value(content = Text(rollNumber), classes = "govuk-!-width-one-half") + ) + } + ).flatten + )) + + @p { + @messages("pages.checkBankDetails.p1") + } + + @formWithCSRF(action = controllers.bankdetails.routes.CheckBankDetailsController.submit) { + @button("app.common.confirmAndContinue") + } +} \ No newline at end of file diff --git a/conf/app.routes b/conf/app.routes index d66ea6c59..039d9feb2 100644 --- a/conf/app.routes +++ b/conf/app.routes @@ -134,6 +134,10 @@ POST /choose-account-type controllers GET /account-details controllers.bankdetails.UkBankAccountDetailsController.show POST /account-details controllers.bankdetails.UkBankAccountDetailsController.submit +# CHECK BANK DETAILS +GET /check-bank-details controllers.bankdetails.CheckBankDetailsController.show +POST /check-bank-details controllers.bankdetails.CheckBankDetailsController.submit + ## VAT CORRESPONDENCE GET /vat-correspondence-language controllers.business.VatCorrespondenceController.show POST /vat-correspondence-language controllers.business.VatCorrespondenceController.submit diff --git a/conf/application.conf b/conf/application.conf index bc1f88a86..27ecc59fe 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -182,6 +182,11 @@ microservice { port = 9871 } + bank-account-reputation-stub { + host = localhost + port = 9871 + } + industry-classification-lookup-frontend { host = localhost port = 9874 diff --git a/conf/messages b/conf/messages index 42b962bfe..5782dcd29 100644 --- a/conf/messages +++ b/conf/messages @@ -1020,6 +1020,16 @@ validation.companyBankAccount.sortCode.length = Sort code must be 6 di validation.companyBankAccount.invalidCombination = Enter a valid bank account number and sort code validation.companyBankAccount.rollNumber.invalid = Building society roll number must be shorter +# Check Bank Details Page +pages.checkBankDetails.heading = Check your account details +pages.checkBankDetails.cardTitle = Bank account details +pages.checkBankDetails.change = Change +pages.checkBankDetails.accountName = Account name +pages.checkBankDetails.accountNumber = Account number +pages.checkBankDetails.sortCode = Sort code +pages.checkBankDetails.rollNumber = Building society roll number +pages.checkBankDetails.p1 = By confirming these account details, you agree the information you have provided is complete and correct. + # Main Business Activity Page pages.mainBusinessActivity.heading = Which activity is the business’s main source of income? validation.mainBusinessActivity.missing = Select the business’s main source of income diff --git a/conf/messages.cy b/conf/messages.cy index 7dfc2d624..7e3712200 100644 --- a/conf/messages.cy +++ b/conf/messages.cy @@ -1019,6 +1019,16 @@ validation.companyBankAccount.sortCode.format = Cod didoli: mae’n rhaid validation.companyBankAccount.invalidCombination = Nodwch rif cyfrif banc a chod didoli dilys validation.companyBankAccount.rollNumber.invalid = Mae’n rhaid i rif rôl y gymdeithas adeiladu fod yn fyrrach +# Check Bank Details Page +pages.checkBankDetails.heading = Gwiriwch fanylion eich cyfrif +pages.checkBankDetails.cardTitle = Manylion cyfrif banc +pages.checkBankDetails.change = Newid +pages.checkBankDetails.accountName = Enw’r cyfrif +pages.checkBankDetails.accountNumber = Rhif y cyfrif +pages.checkBankDetails.sortCode = Cod didoli +pages.checkBankDetails.rollNumber = Rhif rôl y gymdeithas adeiladu +pages.checkBankDetails.p1 = Drwy gadarnhau manylion y cyfrif hwn, rydych yn cytuno bod yr wybodaeth rydych wedi’u darparu yn gyflawn ac yn gywir. + # Main Business Activity Page pages.mainBusinessActivity.heading = Pa weithgaredd yw prif ffynhonnell incwm y busnes? validation.mainBusinessActivity.missing = Dewiswch brif ffynhonnell incwm y busnes diff --git a/it/test/controllers/bankdetails/CheckBankDetailsControllerISpec.scala b/it/test/controllers/bankdetails/CheckBankDetailsControllerISpec.scala new file mode 100644 index 000000000..d6e82b50a --- /dev/null +++ b/it/test/controllers/bankdetails/CheckBankDetailsControllerISpec.scala @@ -0,0 +1,204 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers.bankdetails + +import featuretoggle.FeatureSwitch.UseNewBarsVerify +import itFixtures.ITRegistrationFixtures +import itutil.ControllerISpec +import models.BankAccount +import models.bars.BankAccountType +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import play.api.libs.ws.WSResponse +import play.api.test.Helpers._ +import play.mvc.Http.HeaderNames + +class CheckBankDetailsControllerISpec extends ControllerISpec with ITRegistrationFixtures { + + val url = "/check-bank-details" + + "GET /check-bank-details" when { + + "UseNewBarsVerify is disabled" must { + + "redirect to HasBankAccountController" in new Setup { + disable(UseNewBarsVerify) + given().user.isAuthorised() + + insertCurrentProfileIntoDb(currentProfile, sessionString) + + val res: WSResponse = await(buildClient(url).get()) + + res.status mustBe SEE_OTHER + res.header(HeaderNames.LOCATION) mustBe Some(routes.HasBankAccountController.show.url) + } + } + + "UseNewBarsVerify is enabled" must { + + "redirect to HasBankAccountController when session is empty" in new Setup { + enable(UseNewBarsVerify) + given().user.isAuthorised() + + insertCurrentProfileIntoDb(currentProfile, sessionString) + + val res: WSResponse = await(buildClient(url).get()) + + res.status mustBe SEE_OTHER + res.header(HeaderNames.LOCATION) mustBe Some(routes.HasBankAccountController.show.url) + } + + "return OK and display bank details when session contains bank details" in new Setup { + enable(UseNewBarsVerify) + given().user.isAuthorised() + + insertCurrentProfileIntoDb(currentProfile, sessionString) + + await( + buildClient("/account-details").post( + Map( + "accountName" -> testBankName, + "accountNumber" -> testAccountNumber, + "sortCode" -> testSortCode, + "rollNumber" -> testRollNumber + ))) + + val res: WSResponse = await(buildClient(url).get()) + val doc: Document = Jsoup.parse(res.body) + + res.status mustBe OK + doc.body().text() must include(testBankName) + doc.body().text() must include(testAccountNumber) + doc.body().text() must include(testSortCode) + doc.body().text() must include(testRollNumber) + } + } + } + + "POST /check-bank-details" when { + + "UseNewBarsVerify is disabled" must { + + "redirect to HasBankAccountController" in new Setup { + disable(UseNewBarsVerify) + given().user.isAuthorised() + + insertCurrentProfileIntoDb(currentProfile, sessionString) + + val res: WSResponse = await(buildClient(url).post(Map.empty[String, String])) + + res.status mustBe SEE_OTHER + res.header(HeaderNames.LOCATION) mustBe Some(routes.HasBankAccountController.show.url) + } + } + + "UseNewBarsVerify is enabled" must { + + "redirect to TaskList when BARS verification passes" in new Setup { + enable(UseNewBarsVerify) + given().user + .isAuthorised() + .bars + .verifySucceeds(BankAccountType.Business) + .registrationApi + .getSection[BankAccount](Some(BankAccount(isProvided = true, None, None, Some(BankAccountType.Business)))) + .registrationApi + .replaceSection[BankAccount]( + BankAccount( + isProvided = true, + details = Some(testUkBankDetails), + reason = None, + bankAccountType = Some(BankAccountType.Business) + )) + insertCurrentProfileIntoDb(currentProfile, sessionString) + + await( + buildClient("/account-details").post( + Map( + "accountName" -> testBankName, + "accountNumber" -> testAccountNumber, + "sortCode" -> testSortCode + ))) + + val res: WSResponse = await(buildClient(url).post(Map.empty[String, String])) + + res.status mustBe SEE_OTHER + res.header(HeaderNames.LOCATION) mustBe Some(controllers.routes.TaskListController.show.url) + } + + "redirect back to UkBankAccountDetailsController when BARS verification fails" in new Setup { + enable(UseNewBarsVerify) + given().user + .isAuthorised() + .bars + .verifyFails(BankAccountType.Business, BAD_REQUEST) + .registrationApi + .getSection[BankAccount](Some(BankAccount(isProvided = true, None, None, Some(BankAccountType.Business)))) + + insertCurrentProfileIntoDb(currentProfile, sessionString) + + await( + buildClient("/account-details").post( + Map( + "accountName" -> testBankName, + "accountNumber" -> testAccountNumber, + "sortCode" -> testSortCode + ))) + + val res: WSResponse = await(buildClient(url).post(Map.empty[String, String])) + + res.status mustBe SEE_OTHER + res.header(HeaderNames.LOCATION) mustBe Some(routes.UkBankAccountDetailsController.show.url) + } + + "redirect to HasBankAccountController when session is empty" in new Setup { + enable(UseNewBarsVerify) + given().user.isAuthorised() + + insertCurrentProfileIntoDb(currentProfile, sessionString) + + val res: WSResponse = await(buildClient(url).post(Map.empty[String, String])) + + res.status mustBe SEE_OTHER + res.header(HeaderNames.LOCATION) mustBe Some(routes.HasBankAccountController.show.url) + } + + "redirect to HasBankAccountController if there is no account type saved" in new Setup { + enable(UseNewBarsVerify) + given().user + .isAuthorised() + .registrationApi + .getSection[BankAccount](Some(BankAccount(isProvided = true, None, None, bankAccountType = None))) + + insertCurrentProfileIntoDb(currentProfile, sessionString) + + await( + buildClient("/account-details").post( + Map( + "accountName" -> testBankName, + "accountNumber" -> testAccountNumber, + "sortCode" -> testSortCode + ))) + + val res: WSResponse = await(buildClient(url).post(Map.empty[String, String])) + + res.status mustBe SEE_OTHER + res.header(HeaderNames.LOCATION) mustBe Some(routes.HasBankAccountController.show.url) + } + } + } +} diff --git a/it/test/controllers/bankdetails/UKBankAccountDetailsControllerISpec.scala b/it/test/controllers/bankdetails/UKBankAccountDetailsControllerISpec.scala index f4364727c..08a9b97ac 100644 --- a/it/test/controllers/bankdetails/UKBankAccountDetailsControllerISpec.scala +++ b/it/test/controllers/bankdetails/UKBankAccountDetailsControllerISpec.scala @@ -187,7 +187,7 @@ class UKBankAccountDetailsControllerISpec extends ControllerISpec with ITRegistr "UseNewBarsVerify is enabled" must { - "save bank details to session and redirect to Task List when form is valid" in new Setup { + "save bank details to session and redirect to Check Details Controller when form is valid" in new Setup { enable(UseNewBarsVerify) given().user.isAuthorised() @@ -202,10 +202,10 @@ class UKBankAccountDetailsControllerISpec extends ControllerISpec with ITRegistr ))) res.status mustBe SEE_OTHER - res.header(HeaderNames.LOCATION) mustBe Some(controllers.routes.TaskListController.show.url) + res.header(HeaderNames.LOCATION) mustBe Some(routes.CheckBankDetailsController.show.url) } - "save bank details including roll number to session and redirect to Task List" in new Setup { + "save bank details including roll number to session and redirect to Check Details Controller" in new Setup { enable(UseNewBarsVerify) given().user.isAuthorised() @@ -221,7 +221,7 @@ class UKBankAccountDetailsControllerISpec extends ControllerISpec with ITRegistr ))) res.status mustBe SEE_OTHER - res.header(HeaderNames.LOCATION) mustBe Some(controllers.routes.TaskListController.show.url) + res.header(HeaderNames.LOCATION) mustBe Some(routes.CheckBankDetailsController.show.url) } "return BAD_REQUEST without calling BARS when form fields are empty" in new Setup { diff --git a/test/services/BankAccountDetailsServiceSpec.scala b/test/services/BankAccountDetailsServiceSpec.scala index 58da16c15..00fc15f12 100644 --- a/test/services/BankAccountDetailsServiceSpec.scala +++ b/test/services/BankAccountDetailsServiceSpec.scala @@ -16,29 +16,38 @@ package services +import config.FrontendAppConfig import connectors.mocks.MockRegistrationApiConnector +import featuretoggle.FeatureSwitch.UseNewBarsVerify +import featuretoggle.FeatureToggleSupport.{disable, enable} import models.{BankAccount, BankAccountDetails, BeingSetupOrNameChange} +import models.api.{BankAccountDetailsStatus, IndeterminateStatus, InvalidStatus, ValidStatus} import models.bars.BankAccountType -import models.bars.BankAccountType.Personal import org.mockito.ArgumentMatchers.{any, eq => eqTo} import org.mockito.Mockito._ -import org.scalatest.Assertion +import org.scalatest.{Assertion, BeforeAndAfterEach} +import org.scalatestplus.play.guice.GuiceOneAppPerSuite import play.api.mvc.Request import play.api.test.FakeRequest import testHelpers.VatSpec -class BankAccountDetailsServiceSpec extends VatSpec with MockRegistrationApiConnector { +import scala.concurrent.Future + +class BankAccountDetailsServiceSpec extends VatSpec with GuiceOneAppPerSuite with MockRegistrationApiConnector { val mockBankAccountRepService: BankAccountReputationService = mock[BankAccountReputationService] + val mockBarsService: BarsService = mock[BarsService] trait Setup { val service: BankAccountDetailsService = new BankAccountDetailsService( mockRegistrationApiConnector, - mockBankAccountRepService + mockBankAccountRepService, + mockBarsService ) } - implicit val request: Request[_] = FakeRequest() + implicit val appConfig: FrontendAppConfig = app.injector.instanceOf[FrontendAppConfig] + implicit val request: Request[_] = FakeRequest() "getBankAccountDetails" should { @@ -129,6 +138,114 @@ class BankAccountDetailsServiceSpec extends VatSpec with MockRegistrationApiConn } } + "selectBarsEndpoint" should { + + "call barsService and return ValidStatus when UseNewBarsVerify is enabled" in new Setup { + enable(UseNewBarsVerify) + val bankAccountDetails = BankAccountDetails("testName", "12345678", "123456", None) + val bankAccountType = Some(BankAccountType.Business) + + when(mockBarsService.verifyBankDetails(any(), any())(any())) + .thenReturn(Future.successful(ValidStatus)) + + val result: BankAccountDetailsStatus = await(service.selectBarsEndpoint(bankAccountDetails, bankAccountType)) + + result mustBe ValidStatus + verify(mockBarsService, times(1)).verifyBankDetails(eqTo(bankAccountDetails), eqTo(BankAccountType.Business))(any()) + verifyNoInteractions(mockBankAccountRepService) + reset(mockBarsService) + } + + "call barsService and return IndeterminateStatus when UseNewBarsVerify is enabled" in new Setup { + enable(UseNewBarsVerify) + val bankAccountDetails = BankAccountDetails("testName", "12345678", "123456", None) + val bankAccountType = Some(BankAccountType.Personal) + + when(mockBarsService.verifyBankDetails(any(), any())(any())) + .thenReturn(Future.successful(IndeterminateStatus)) + + val result: BankAccountDetailsStatus = await(service.selectBarsEndpoint(bankAccountDetails, bankAccountType)) + + result mustBe IndeterminateStatus + verify(mockBarsService, times(1)).verifyBankDetails(eqTo(bankAccountDetails), eqTo(BankAccountType.Personal))(any()) + verifyNoInteractions(mockBankAccountRepService) + reset(mockBarsService) + } + + "call barsService and return InvalidStatus when UseNewBarsVerify is enabled" in new Setup { + enable(UseNewBarsVerify) + val bankAccountDetails = BankAccountDetails("testName", "12345678", "123456", None) + val bankAccountType = Some(BankAccountType.Business) + + when(mockBarsService.verifyBankDetails(any(), any())(any())) + .thenReturn(Future.successful(InvalidStatus)) + + val result: BankAccountDetailsStatus = await(service.selectBarsEndpoint(bankAccountDetails, bankAccountType)) + + result mustBe InvalidStatus + verify(mockBarsService, times(1)).verifyBankDetails(eqTo(bankAccountDetails), eqTo(BankAccountType.Business))(any()) + verifyNoInteractions(mockBankAccountRepService) + reset(mockBarsService) + } + + "throw IllegalStateException when UseNewBarsVerify is enabled and bankAccountType is None" in new Setup { + enable(UseNewBarsVerify) + val bankAccountDetails = BankAccountDetails("testName", "12345678", "123456", None) + + intercept[IllegalStateException] { + await(service.selectBarsEndpoint(bankAccountDetails, None)) + }.getMessage mustBe "bankAccountType is required when UseNewBarsVerify is enabled" + + verifyNoInteractions(mockBarsService) + verifyNoInteractions(mockBankAccountRepService) + reset(mockBarsService) + } + + "call bankAccountRepService and return ValidStatus when UseNewBarsVerify is disabled" in new Setup { + disable(UseNewBarsVerify) + val bankAccountDetails = BankAccountDetails("testName", "12345678", "123456", None) + + when(mockBankAccountRepService.validateBankDetails(any())(any(), any())) + .thenReturn(Future.successful(ValidStatus)) + + val result: BankAccountDetailsStatus = await(service.selectBarsEndpoint(bankAccountDetails, None)) + + result mustBe ValidStatus + verify(mockBankAccountRepService, times(1)).validateBankDetails(eqTo(bankAccountDetails))(any(), any()) + verifyNoInteractions(mockBarsService) + reset(mockBankAccountRepService) + } + + "call bankAccountRepService and return IndeterminateStatus when UseNewBarsVerify is disabled" in new Setup { + disable(UseNewBarsVerify) + val bankAccountDetails = BankAccountDetails("testName", "12345678", "123456", None) + + when(mockBankAccountRepService.validateBankDetails(any())(any(), any())) + .thenReturn(Future.successful(IndeterminateStatus)) + + val result: BankAccountDetailsStatus = await(service.selectBarsEndpoint(bankAccountDetails, None)) + + result mustBe IndeterminateStatus + verify(mockBankAccountRepService, times(1)).validateBankDetails(eqTo(bankAccountDetails))(any(), any()) + verifyNoInteractions(mockBarsService) + reset(mockBankAccountRepService) + } + + "call bankAccountRepService and return InvalidStatus when UseNewBarsVerify is disabled" in new Setup { + disable(UseNewBarsVerify) + val bankAccountDetails = BankAccountDetails("testName", "12345678", "123456", None) + + when(mockBankAccountRepService.validateBankDetails(any())(any(), any())) + .thenReturn(Future.successful(InvalidStatus)) + + val result: BankAccountDetailsStatus = await(service.selectBarsEndpoint(bankAccountDetails, None)) + + result mustBe InvalidStatus + verify(mockBankAccountRepService, times(1)).validateBankDetails(eqTo(bankAccountDetails))(any(), any()) + verifyNoInteractions(mockBarsService) + reset(mockBankAccountRepService) + } + } "saveNoUkBankAccountDetails" should { "save a BankAccount reason and remove bank account details" in new Setup { val existing: BankAccount = BankAccount(isProvided = true, None, None, Some(BankAccountType.Business)) diff --git a/test/services/BarsServiceSpec.scala b/test/services/BarsServiceSpec.scala index c6cda1b50..a1a8436fc 100644 --- a/test/services/BarsServiceSpec.scala +++ b/test/services/BarsServiceSpec.scala @@ -91,7 +91,7 @@ class BarsServiceSpec extends AnyWordSpec with Matchers with MockitoSugar with S when(mockConnector.verify(any(), any())(any())) .thenReturn(Future.successful(successResponse)) - service.verifyBankDetails(BankAccountType.Personal, personalDetails).futureValue shouldBe ValidStatus + service.verifyBankDetails(personalDetails, BankAccountType.Personal).futureValue shouldBe ValidStatus } } @@ -100,7 +100,7 @@ class BarsServiceSpec extends AnyWordSpec with Matchers with MockitoSugar with S when(mockConnector.verify(any(), any())(any())) .thenReturn(Future.successful(barsResponseWith(accountExists = BarsResponse.Indeterminate))) - service.verifyBankDetails(BankAccountType.Personal, personalDetails).futureValue shouldBe IndeterminateStatus + service.verifyBankDetails(personalDetails, BankAccountType.Personal).futureValue shouldBe IndeterminateStatus } } @@ -109,14 +109,14 @@ class BarsServiceSpec extends AnyWordSpec with Matchers with MockitoSugar with S when(mockConnector.verify(any(), any())(any())) .thenReturn(Future.successful(barsResponseWith(sortCodeIsPresentOnEISCD = BarsResponse.No))) - service.verifyBankDetails(BankAccountType.Personal, personalDetails).futureValue shouldBe InvalidStatus + service.verifyBankDetails(personalDetails, BankAccountType.Personal).futureValue shouldBe InvalidStatus } "the connector throws an exception" in { when(mockConnector.verify(any(), any())(any())) .thenReturn(Future.failed(new RuntimeException("failure"))) - service.verifyBankDetails(BankAccountType.Personal, personalDetails).futureValue shouldBe InvalidStatus + service.verifyBankDetails(personalDetails, BankAccountType.Personal).futureValue shouldBe InvalidStatus } } } @@ -180,7 +180,7 @@ class BarsServiceSpec extends AnyWordSpec with Matchers with MockitoSugar with S "return a JSON body with 'account' and 'subject' keys" when { "given a Personal account type" in { - val result = service.buildJsonRequestBody(BankAccountType.Personal, personalDetails) + val result = service.buildJsonRequestBody(personalDetails, BankAccountType.Personal) result shouldBe Json.toJson( BarsPersonalRequest( @@ -193,14 +193,14 @@ class BarsServiceSpec extends AnyWordSpec with Matchers with MockitoSugar with S "not include a 'business' key" when { "given a Personal account type" in { - val result = service.buildJsonRequestBody(BankAccountType.Personal, personalDetails) + val result = service.buildJsonRequestBody(personalDetails, BankAccountType.Personal) (result \ "business").isDefined shouldBe false } } "return a JSON body with 'account' and 'business' keys" when { "given a Business account type" in { - val result = service.buildJsonRequestBody(BankAccountType.Business, businessDetails) + val result = service.buildJsonRequestBody(businessDetails, BankAccountType.Business) result shouldBe Json.toJson( BarsBusinessRequest( @@ -213,7 +213,7 @@ class BarsServiceSpec extends AnyWordSpec with Matchers with MockitoSugar with S "not include a 'subject' key" when { "given a Business account type" in { - val result = service.buildJsonRequestBody(BankAccountType.Business, businessDetails) + val result = service.buildJsonRequestBody(businessDetails, BankAccountType.Business ) (result \ "subject").isDefined shouldBe false } } diff --git a/test/views/bankdetails/CheckBankDetailsViewSpec.scala b/test/views/bankdetails/CheckBankDetailsViewSpec.scala new file mode 100644 index 000000000..9324190e0 --- /dev/null +++ b/test/views/bankdetails/CheckBankDetailsViewSpec.scala @@ -0,0 +1,123 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package views.bankdetails + +import models.BankAccountDetails +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import views.VatRegViewSpec +import views.html.bankdetails.CheckBankDetailsView + +class CheckBankDetailsViewSpec extends VatRegViewSpec { + + val view: CheckBankDetailsView = app.injector.instanceOf[CheckBankDetailsView] + + val title = "Check your account details" + val heading = "Check your account details" + val cardTitle = "Bank account details" + val changeLink = "Change" + val accountNameLabel = "Account name" + val accountNumberLabel = "Account number" + val sortCodeLabel = "Sort code" + val rollNumberLabel = "Building society roll number" + val p1 = "By confirming these account details, you agree the information you have provided is complete and correct." + val buttonText = "Confirm and continue" + + val bankDetails: BankAccountDetails = BankAccountDetails( + name = "Test Account", + sortCode = "123456", + number = "12345678", + rollNumber = None, + status = None + ) + + val bankDetailsWithRollNumber: BankAccountDetails = bankDetails.copy(rollNumber = Some("AB/121212")) + + "CheckBankDetailsView" should { + + implicit lazy val doc: Document = Jsoup.parse(view(bankDetails).body) + + "have the correct title" in new ViewSetup { + doc.title must include(title) + } + + "have the correct heading" in new ViewSetup { + doc.heading mustBe Some(heading) + } + + "have the correct card title" in new ViewSetup { + doc.select(".govuk-summary-card__title").text mustBe cardTitle + } + + "have a change link" in new ViewSetup { + doc.select(".govuk-summary-card__actions a").text must include(changeLink) + } + + "have the correct account name label" in new ViewSetup { + doc.select(".govuk-summary-list__key").get(0).text mustBe accountNameLabel + } + + "have the correct account name value" in new ViewSetup { + doc.select(".govuk-summary-list__value").get(0).text mustBe "Test Account" + } + + "have the correct account number label" in new ViewSetup { + doc.select(".govuk-summary-list__key").get(1).text mustBe accountNumberLabel + } + + "have the correct account number value" in new ViewSetup { + doc.select(".govuk-summary-list__value").get(1).text mustBe "12345678" + } + + "have the correct sort code label" in new ViewSetup { + doc.select(".govuk-summary-list__key").get(2).text mustBe sortCodeLabel + } + + "have the correct sort code value" in new ViewSetup { + doc.select(".govuk-summary-list__value").get(2).text mustBe "123456" + } + + "not show roll number row when roll number is absent" in new ViewSetup { + doc.select(".govuk-summary-list__key").size mustBe 3 + } + + "have the correct p1" in new ViewSetup { + doc.para(1) mustBe Some(p1) + } + + "have the correct continue button" in new ViewSetup { + doc.submitButton mustBe Some(buttonText) + } + } + + "CheckBankDetailsView with roll number" should { + + implicit lazy val doc: Document = Jsoup.parse(view(bankDetailsWithRollNumber).body) + + "show the roll number row when roll number is present" in new ViewSetup { + doc.select(".govuk-summary-list__key").size mustBe 4 + } + + "have the correct roll number label" in new ViewSetup { + doc.select(".govuk-summary-list__key").get(3).text mustBe rollNumberLabel + } + + "have the correct roll number value" in new ViewSetup { + doc.select(".govuk-summary-list__value").get(3).text mustBe "AB/121212" + } + } +} \ No newline at end of file From 00e7cf351aa6d2a8f2735dd06f68ae5a4d55bfc5 Mon Sep 17 00:00:00 2001 From: mi-aspectratio <254715997+mi-aspectratio@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:39:19 +0000 Subject: [PATCH 14/27] feat: ADD check bank details controller, views and specs --- .../CheckBankDetailsController.scala | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/app/controllers/bankdetails/CheckBankDetailsController.scala b/app/controllers/bankdetails/CheckBankDetailsController.scala index 3551eab0d..5ed0f25af 100644 --- a/app/controllers/bankdetails/CheckBankDetailsController.scala +++ b/app/controllers/bankdetails/CheckBankDetailsController.scala @@ -49,11 +49,11 @@ class CheckBankDetailsController @Inject() ( private val sessionKey = "bankAccountDetails" - def show: Action[AnyContent] = isAuthenticated { implicit request => + def show: Action[AnyContent] = isAuthenticatedWithProfile { implicit request => implicit profile => if (isEnabled(UseNewBarsVerify)) { sessionService.fetchAndGet[BankAccountDetails](sessionKey).map { case Some(details) => Ok(view(details)) - case None => Redirect(routes.HasBankAccountController.show) + case None => Redirect(routes.UkBankAccountDetailsController.show) } } else { Future.successful(Redirect(routes.HasBankAccountController.show)) @@ -62,18 +62,16 @@ class CheckBankDetailsController @Inject() ( def submit: Action[AnyContent] = isAuthenticatedWithProfile { implicit request => implicit profile => if (isEnabled(UseNewBarsVerify)) { - for { - details <- sessionService.fetchAndGet[BankAccountDetails](sessionKey) - bankAccount <- bankAccountDetailsService.getBankAccount - result <- (details, bankAccount.flatMap(_.bankAccountType)) match { - case (Some(accountDetails), Some(accountType)) => - bankAccountDetailsService.saveEnteredBankAccountDetails(accountDetails, Some(accountType)).map { - case true => Redirect(controllers.routes.TaskListController.show.url) - case false => Redirect(routes.UkBankAccountDetailsController.show) + sessionService.fetchAndGet[BankAccountDetails](sessionKey).flatMap { + case Some(details) => + bankAccountDetailsService.getBankAccount.flatMap { bankAccount => + bankAccountDetailsService.saveEnteredBankAccountDetails(details, bankAccount.flatMap(_.bankAccountType)).flatMap { + case true => sessionService.remove.map(_ => Redirect(controllers.routes.TaskListController.show.url)) + case false => Future.successful(Redirect(routes.UkBankAccountDetailsController.show)) } - case _ => Future.successful(Redirect(routes.HasBankAccountController.show)) - } - } yield result + } + case None => Future.successful(Redirect(routes.UkBankAccountDetailsController.show)) + } } else { Future.successful(Redirect(routes.HasBankAccountController.show)) } From d3658bc9ca59480708672fcd8d459fd21cff679a Mon Sep 17 00:00:00 2001 From: mi-aspectratio <254715997+mi-aspectratio@users.noreply.github.com> Date: Sat, 14 Mar 2026 08:36:29 +0000 Subject: [PATCH 15/27] feat: check session for details and account type before starting verification and redicrect to hasbankcontroller + add stub config --- .../CheckBankDetailsController.scala | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/app/controllers/bankdetails/CheckBankDetailsController.scala b/app/controllers/bankdetails/CheckBankDetailsController.scala index 5ed0f25af..3551eab0d 100644 --- a/app/controllers/bankdetails/CheckBankDetailsController.scala +++ b/app/controllers/bankdetails/CheckBankDetailsController.scala @@ -49,11 +49,11 @@ class CheckBankDetailsController @Inject() ( private val sessionKey = "bankAccountDetails" - def show: Action[AnyContent] = isAuthenticatedWithProfile { implicit request => implicit profile => + def show: Action[AnyContent] = isAuthenticated { implicit request => if (isEnabled(UseNewBarsVerify)) { sessionService.fetchAndGet[BankAccountDetails](sessionKey).map { case Some(details) => Ok(view(details)) - case None => Redirect(routes.UkBankAccountDetailsController.show) + case None => Redirect(routes.HasBankAccountController.show) } } else { Future.successful(Redirect(routes.HasBankAccountController.show)) @@ -62,16 +62,18 @@ class CheckBankDetailsController @Inject() ( def submit: Action[AnyContent] = isAuthenticatedWithProfile { implicit request => implicit profile => if (isEnabled(UseNewBarsVerify)) { - sessionService.fetchAndGet[BankAccountDetails](sessionKey).flatMap { - case Some(details) => - bankAccountDetailsService.getBankAccount.flatMap { bankAccount => - bankAccountDetailsService.saveEnteredBankAccountDetails(details, bankAccount.flatMap(_.bankAccountType)).flatMap { - case true => sessionService.remove.map(_ => Redirect(controllers.routes.TaskListController.show.url)) - case false => Future.successful(Redirect(routes.UkBankAccountDetailsController.show)) + for { + details <- sessionService.fetchAndGet[BankAccountDetails](sessionKey) + bankAccount <- bankAccountDetailsService.getBankAccount + result <- (details, bankAccount.flatMap(_.bankAccountType)) match { + case (Some(accountDetails), Some(accountType)) => + bankAccountDetailsService.saveEnteredBankAccountDetails(accountDetails, Some(accountType)).map { + case true => Redirect(controllers.routes.TaskListController.show.url) + case false => Redirect(routes.UkBankAccountDetailsController.show) } - } - case None => Future.successful(Redirect(routes.UkBankAccountDetailsController.show)) - } + case _ => Future.successful(Redirect(routes.HasBankAccountController.show)) + } + } yield result } else { Future.successful(Redirect(routes.HasBankAccountController.show)) } From 1d89ea2269ed37db70a03bbf6d0b3b40c1e77642 Mon Sep 17 00:00:00 2001 From: HG-Accenture <183600681+HG-Accenture@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:41:26 +0000 Subject: [PATCH 16/27] Lock service --- app/config/FrontendAppConfig.scala | 9 ++ .../AccountDetailsNotVerified.scala | 54 +++++++++ .../UkBankAccountDetailsController.scala | 23 ++-- .../ThirdAttemptLockoutController.scala | 42 +++++++ app/models/Lock.scala | 31 +++++ app/repositories/UserLockRepository.scala | 108 ++++++++++++++++++ app/services/LockService.scala | 62 ++++++++++ .../AccountDetailsNotVerifiedView.scala.html | 57 +++++++++ .../errors/ThirdAttemptLockoutPage.scala.html | 49 ++++++++ conf/messages | 11 ++ .../AccountDetailsNotVerifiedSpec.scala | 87 ++++++++++++++ .../ThirdAttemptLockoutControllerSpec.scala | 53 +++++++++ test/services/LockServiceSpec.scala | 102 +++++++++++++++++ .../HasCompanyBankAccountViewSpec.scala | 16 +-- 14 files changed, 690 insertions(+), 14 deletions(-) create mode 100644 app/controllers/bankdetails/AccountDetailsNotVerified.scala create mode 100644 app/controllers/errors/ThirdAttemptLockoutController.scala create mode 100644 app/models/Lock.scala create mode 100644 app/repositories/UserLockRepository.scala create mode 100644 app/services/LockService.scala create mode 100644 app/views/bankdetails/AccountDetailsNotVerifiedView.scala.html create mode 100644 app/views/errors/ThirdAttemptLockoutPage.scala.html create mode 100644 test/controllers/bankdetails/AccountDetailsNotVerifiedSpec.scala create mode 100644 test/controllers/errors/ThirdAttemptLockoutControllerSpec.scala create mode 100644 test/services/LockServiceSpec.scala diff --git a/app/config/FrontendAppConfig.scala b/app/config/FrontendAppConfig.scala index cd7814e3f..1d1c93196 100644 --- a/app/config/FrontendAppConfig.scala +++ b/app/config/FrontendAppConfig.scala @@ -45,6 +45,13 @@ class FrontendAppConfig @Inject()(val servicesConfig: ServicesConfig, runModeCon lazy val eligibilityQuestionUrl: String = loadConfig("microservice.services.vat-registration-eligibility-frontend.question") implicit val appConfig: FrontendAppConfig = this + lazy val ttlLockSeconds:Int = 86400 + lazy val knownFactsLockAttemptLimit:Int = 3 + lazy val isKnownFactsCheckEnabled:Boolean = true + + + + private lazy val thresholdString: String = runModeConfiguration.get[ConfigList]("vat-threshold").render(ConfigRenderOptions.concise()) lazy val thresholds: Seq[VatThreshold] = Json.parse(thresholdString).as[List[VatThreshold]] @@ -290,6 +297,8 @@ class FrontendAppConfig @Inject()(val servicesConfig: ServicesConfig, runModeCon lazy val govukHowToRegister: String = "https://www.gov.uk/register-for-vat/how-register-for-vat" + lazy val vatTaskList: String = s"$host/register-for-vat/application-progress" + lazy val govukTogcVatNotice: String = "https://www.gov.uk/guidance/transfer-a-business-as-a-going-concern-and-vat-notice-7009" lazy val businessDescriptionMaxLength: Int = servicesConfig.getInt("constants.businessDescriptionMaxLength") diff --git a/app/controllers/bankdetails/AccountDetailsNotVerified.scala b/app/controllers/bankdetails/AccountDetailsNotVerified.scala new file mode 100644 index 000000000..6a6884afb --- /dev/null +++ b/app/controllers/bankdetails/AccountDetailsNotVerified.scala @@ -0,0 +1,54 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers.bankdetails + +import config.{AuthClientConnector, BaseControllerComponents, FrontendAppConfig} +import controllers.BaseController +import play.api.data.Form +import play.api.data.Forms.{boolean, single} +import play.api.mvc.{Action, AnyContent} +import services.{LockService, SessionService} +import views.html.bankdetails.AccountDetailsNotVerifiedView + +import javax.inject.Inject +import scala.concurrent.ExecutionContext + +class AccountDetailsNotVerified @Inject()(val authConnector: AuthClientConnector, + val sessionService: SessionService, + lockService: LockService, + view: AccountDetailsNotVerifiedView) + (implicit appConfig: FrontendAppConfig, + val executionContext: ExecutionContext, + baseControllerComponents: BaseControllerComponents) extends BaseController { + + private val AttemptForm: Form[Boolean] = Form(single("value" -> boolean)) + + def show: Action[AnyContent] = isAuthenticatedWithProfile { + implicit request => implicit profile => + lockService.getBarsAttemptsUsed(profile.registrationId).map { attemptsUsed => + if (attemptsUsed >= appConfig.knownFactsLockAttemptLimit) { + Redirect(controllers.errors.routes.ThirdAttemptLockoutController.show) + } else { + val formWithAttempts = AttemptForm.bind(Map( + "value" -> "true", + "attempts" -> attemptsUsed.toString + )) + Ok(view(formWithAttempts)) + } + } + } +} diff --git a/app/controllers/bankdetails/UkBankAccountDetailsController.scala b/app/controllers/bankdetails/UkBankAccountDetailsController.scala index 4be977f9f..243bb08e2 100644 --- a/app/controllers/bankdetails/UkBankAccountDetailsController.scala +++ b/app/controllers/bankdetails/UkBankAccountDetailsController.scala @@ -27,7 +27,7 @@ import models.bars.BankAccountDetailsSessionFormat import play.api.libs.json.Format import play.api.mvc.{Action, AnyContent} import play.api.Configuration -import services.{BankAccountDetailsService, SessionService} +import services.{BankAccountDetailsService,LockService, SessionService} import uk.gov.hmrc.crypto.SymmetricCryptoFactory import views.html.bankdetails.{EnterBankAccountDetails, EnterCompanyBankAccountDetails} @@ -38,6 +38,7 @@ class UkBankAccountDetailsController @Inject() ( val authConnector: AuthClientConnector, val bankAccountDetailsService: BankAccountDetailsService, val sessionService: SessionService, + val lockService: LockService, configuration: Configuration, newBarsView: EnterBankAccountDetails, oldView: EnterCompanyBankAccountDetails @@ -54,12 +55,19 @@ class UkBankAccountDetailsController @Inject() ( def show: Action[AnyContent] = isAuthenticatedWithProfile { implicit request => implicit profile => if (isEnabled(UseNewBarsVerify)) { - val newBarsForm = EnterBankAccountDetailsForm.form - sessionService.fetchAndGet[BankAccountDetails](sessionKey).map { - case Some(details) => Ok(newBarsView(newBarsForm.fill(details))) - case None => Ok(newBarsView(newBarsForm)) - } - } else { + lockService.getBarsAttemptsUsed(profile.registrationId).map(_ >= appConfig.knownFactsLockAttemptLimit).flatMap { + case true => Future.successful(Redirect(controllers.errors.routes.ThirdAttemptLockoutController.show)) + case false => + val newBarsForm = EnterBankAccountDetailsForm.form + sessionService.fetchAndGet[BankAccountDetails](sessionKey).map { + case Some(details) => Ok(newBarsView(newBarsForm.fill(details))) + case None => Ok(newBarsView(newBarsForm)) + } + } + } + + + else { for { bankDetails <- bankAccountDetailsService.getBankAccount filledForm = bankDetails.flatMap(_.details).fold(enterBankAccountDetailsForm)(enterBankAccountDetailsForm.fill) @@ -92,4 +100,5 @@ class UkBankAccountDetailsController @Inject() ( ) } } + } diff --git a/app/controllers/errors/ThirdAttemptLockoutController.scala b/app/controllers/errors/ThirdAttemptLockoutController.scala new file mode 100644 index 000000000..2255ae189 --- /dev/null +++ b/app/controllers/errors/ThirdAttemptLockoutController.scala @@ -0,0 +1,42 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers.errors + + +import play.api.mvc.{Action, AnyContent, MessagesControllerComponents} +import uk.gov.hmrc.auth.core.{AuthConnector, AuthorisedFunctions} +import config.FrontendAppConfig +import uk.gov.hmrc.play.bootstrap.frontend.controller.FrontendController +import services.SessionService +import views.html.errors.ThirdAttemptLockoutPage + +import javax.inject.{Inject, Singleton} +import scala.concurrent.{ExecutionContext, Future} + +@Singleton +class ThirdAttemptLockoutController @Inject()(mcc: MessagesControllerComponents, + view: ThirdAttemptLockoutPage, + val authConnector: AuthConnector + )(implicit appConfig: FrontendAppConfig, ec: ExecutionContext) extends FrontendController(mcc) with AuthorisedFunctions { + + def show(): Action[AnyContent] = Action.async { + implicit request => + authorised() { + Future.successful(Ok(view())) + } + } +} \ No newline at end of file diff --git a/app/models/Lock.scala b/app/models/Lock.scala new file mode 100644 index 000000000..e6993794c --- /dev/null +++ b/app/models/Lock.scala @@ -0,0 +1,31 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package models + +import play.api.libs.json.{Format, Json, OFormat} +import uk.gov.hmrc.mongo.play.json.formats.MongoJavatimeFormats + +import java.time.Instant + +case class Lock(identifier: String, + failedAttempts: Int, + lastAttemptedAt: Instant) + +object Lock { + implicit val instantFormat: Format[Instant] = MongoJavatimeFormats.instantFormat + implicit lazy val format: OFormat[Lock] = Json.format +} \ No newline at end of file diff --git a/app/repositories/UserLockRepository.scala b/app/repositories/UserLockRepository.scala new file mode 100644 index 000000000..f5942abc2 --- /dev/null +++ b/app/repositories/UserLockRepository.scala @@ -0,0 +1,108 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package repositories + +import org.mongodb.scala.model.Indexes.ascending +import org.mongodb.scala.model._ +import play.api.libs.json._ +import config.FrontendAppConfig +import models.Lock +import models.Lock._ +import uk.gov.hmrc.mongo.MongoComponent +import uk.gov.hmrc.mongo.play.json.PlayMongoRepository + +import java.time.Instant +import java.util.concurrent.TimeUnit +import javax.inject.{Inject, Singleton} +import scala.concurrent.{ExecutionContext, Future} + +@Singleton +class UserLockRepository @Inject()( + mongoComponent: MongoComponent, + appConfig: FrontendAppConfig + )(implicit ec: ExecutionContext) extends PlayMongoRepository[Lock]( + collectionName = "user-lock", + mongoComponent = mongoComponent, + domainFormat = implicitly[Format[Lock]], + indexes = Seq( + IndexModel( + keys = ascending("lastAttemptedAt"), + indexOptions = IndexOptions() + .name("CVEInvalidDataLockExpires") + .expireAfter(appConfig.ttlLockSeconds, TimeUnit.SECONDS) + ), + IndexModel( + keys = ascending("identifier"), + indexOptions = IndexOptions() + .name("IdentifierIdx") + .sparse(true) + .unique(true) + ) + ), + replaceIndexes = true +) { + + def getFailedAttempts(identifier: String): Future[Int] = + collection + .find(Filters.eq("identifier", identifier)) + .headOption() + .map(_.map(_.failedAttempts).getOrElse(0)) + + def isUserLocked(userId: String): Future[Boolean] = { + collection + .find(Filters.in("identifier", userId)) + .toFuture() + .map { _.exists { _.failedAttempts >= appConfig.knownFactsLockAttemptLimit }} + } + + def updateAttempts(userId: String): Future[Map[String, Int]] = { + def updateAttemptsForLockWith(identifier: String): Future[Lock] = { + collection + .find(Filters.eq("identifier", identifier)) + .headOption() + .flatMap { + case Some(existingLock) => + val newLock = existingLock.copy( + failedAttempts = existingLock.failedAttempts + 1, + lastAttemptedAt = Instant.now() + ) + collection.replaceOne( + Filters.and( + Filters.eq("identifier", identifier) + ), + newLock + ) + .toFuture() + .map(_ => newLock) + case _ => + val newLock = Lock(identifier, 1, Instant.now) + collection.insertOne(newLock) + .toFuture() + .map(_ => newLock) + } + } + val updateUserLock = updateAttemptsForLockWith(userId) + + for { + userLock <- updateUserLock + } yield { + Map( + "user" -> userLock.failedAttempts + ) + } + } +} \ No newline at end of file diff --git a/app/services/LockService.scala b/app/services/LockService.scala new file mode 100644 index 000000000..49f67ad24 --- /dev/null +++ b/app/services/LockService.scala @@ -0,0 +1,62 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package services + + +import play.api.mvc.Result +import play.api.mvc.Results.Redirect +import config.FrontendAppConfig +import controllers.errors +import repositories.UserLockRepository +import utils.LoggingUtil + +import javax.inject.{Inject, Singleton} +import scala.concurrent.{ExecutionContext, Future} + +@Singleton +class LockService @Inject()(userLockRepository: UserLockRepository, + config: FrontendAppConfig)(implicit ec: ExecutionContext) extends LoggingUtil{ + + + + def updateAttempts(userId: String): Future[Map[String, Int]] = { + if (config.isKnownFactsCheckEnabled) { + userLockRepository.updateAttempts(userId) + } else { + Future.successful(Map.empty) + } + } + + def isJourneyLocked(userId: String): Future[Boolean] = { + if (config.isKnownFactsCheckEnabled) { + userLockRepository.isUserLocked(userId) + } else { + Future.successful(false) + } + } + + // ---- BARs bank account lock methods ---- + + def getBarsAttemptsUsed(registrationId: String): Future[Int] = + userLockRepository.getFailedAttempts(registrationId) + + def incrementBarsAttempts(registrationId: String): Future[Int] = + userLockRepository.updateAttempts(registrationId).map(_.getOrElse("user", 0)) + + def isBarsLocked(registrationId: String): Future[Boolean] = + userLockRepository.isUserLocked(registrationId) +} diff --git a/app/views/bankdetails/AccountDetailsNotVerifiedView.scala.html b/app/views/bankdetails/AccountDetailsNotVerifiedView.scala.html new file mode 100644 index 000000000..82bebb867 --- /dev/null +++ b/app/views/bankdetails/AccountDetailsNotVerifiedView.scala.html @@ -0,0 +1,57 @@ +@* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + *@ + +@import config.FrontendAppConfig +@import play.api.data.Form +@import play.api.i18n.Messages +@import play.api.mvc.Request +@import uk.gov.hmrc.govukfrontend.views.html.components._ + +@this( + layout: layouts.layout, + h1: components.h1, + p: components.p, + formWithCSRF: FormWithCSRF, + errorSummary: components.errorSummary, +) + +@(form: Form[Boolean])(implicit request: Request[_], messages: Messages, appConfig: FrontendAppConfig) + + +@layout(pageTitle = Some(title(form, messages("pages.accountDetailsCouldNotBeVerified.heading")))){ + + @errorSummary(errors = form.errors) + + @h1("pages.accountDetailsCouldNotBeVerified.heading") + + @p { + @messages("pages.accountDetailsCouldNotBeVerified.para1", 3 - form.data.get("attempts").map(_.toInt).getOrElse(0)) + } + + @p { + @Html(messages("pages.accountDetailsCouldNotBeVerified.para2", appConfig.vatTaskList)) + } +@* + * @p { + * @messages("pages.accountDetailsCouldNotBeVerified.para2") + * } +*@ + + @p { + @messages("pages.accountDetailsCouldNotBeVerified.para3.bold") @messages("pages.accountDetailsCouldNotBeVerified.para3") + } + +} \ No newline at end of file diff --git a/app/views/errors/ThirdAttemptLockoutPage.scala.html b/app/views/errors/ThirdAttemptLockoutPage.scala.html new file mode 100644 index 000000000..d348775a8 --- /dev/null +++ b/app/views/errors/ThirdAttemptLockoutPage.scala.html @@ -0,0 +1,49 @@ +@* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + *@ + +@import config.FrontendAppConfig +@import play.api.i18n.Messages +@import play.api.mvc.Request +@import views.html.components._ +@import views.html.layouts.layout +@import uk.gov.hmrc.govukfrontend.views.html.components._ + + +@this(layout: layout, + h1: h1, + p: p, + govUkHeader: GovukHeader, + govukButton: GovukButton +) + +@()(implicit request: Request[_], messages: Messages, appConfig: FrontendAppConfig) + +@layout(Some(titleNoForm(messages("ThirdAttemptLockoutPage.heading"))), backLink = false) { + + @h1(messages("ThirdAttemptLockoutPage.heading")) + + @p { + @messages("pages.ThirdAttemptLockoutPage.para1") + } + + @p { + @messages("pages.ThirdAttemptLockoutPage.para2") + } + + @p { + @Html(messages("pages.ThirdAttemptLockoutPage.para3", appConfig.vatTaskList)) + } +} \ No newline at end of file diff --git a/conf/messages b/conf/messages index 5782dcd29..c62bcfe0e 100644 --- a/conf/messages +++ b/conf/messages @@ -2050,3 +2050,14 @@ partnerEmail.error.incorrect_format = Enter the email address in the partnerEmail.error.nothing_entered = Enter the email address partnerEmail.error.incorrect_length = The email address must be 132 characters or fewer +#Account Details could not be verified +pages.accountDetailsCouldNotBeVerified.heading = We could not verify the bank details you provided +pages.accountDetailsCouldNotBeVerified.para1 = You have {0} more attempts to provide your account details. +pages.accountDetailsCouldNotBeVerified.para2 = Enter your bank account or building society details again, making sure the name matches exactly as it appears on your account. +pages.accountDetailsCouldNotBeVerified.para3.bold = After 3 consecutive unsuccessful attempts, +pages.accountDetailsCouldNotBeVerified.para3 = you will need to complete your VAT registration before sending us your details. + +ThirdAttemptLockoutPage.heading = Account details could not be verified +pages.ThirdAttemptLockoutPage.para1 = We have been unable to verify the account details you supplied. +pages.ThirdAttemptLockoutPage.para2 = For your security, we have paused this part of the service. +pages.ThirdAttemptLockoutPage.para3 = You can return to the VAT registration task list now, and provide your account details later once your registration is confirmed. \ No newline at end of file diff --git a/test/controllers/bankdetails/AccountDetailsNotVerifiedSpec.scala b/test/controllers/bankdetails/AccountDetailsNotVerifiedSpec.scala new file mode 100644 index 000000000..e58c58d10 --- /dev/null +++ b/test/controllers/bankdetails/AccountDetailsNotVerifiedSpec.scala @@ -0,0 +1,87 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers.bankdetails + +import fixtures.VatRegistrationFixture +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito._ +import play.api.test.FakeRequest +import services.LockService +import testHelpers.{ControllerSpec, FutureAssertions} +import views.html.bankdetails.AccountDetailsNotVerifiedView + +import scala.concurrent.Future + +class AccountDetailsNotVerifiedSpec extends ControllerSpec with VatRegistrationFixture with FutureAssertions { + + val mockLockService: LockService = mock[LockService] + val view: AccountDetailsNotVerifiedView = app.injector.instanceOf[AccountDetailsNotVerifiedView] + + trait Setup { + val testController = new AccountDetailsNotVerified( + mockAuthClientConnector, + mockSessionService, + mockLockService, + view + ) + + mockAuthenticated() + mockWithCurrentProfile(Some(currentProfile)) + } + + "show" should { + "return 200 when attempts used is below the lockout limit" in new Setup { + when(mockLockService.getBarsAttemptsUsed(any())) + .thenReturn(Future.successful(1)) + + callAuthorised(testController.show) { result => + status(result) mustBe OK + } + } + + "return 200 when attempts used is one below the lockout limit" in new Setup { + // knownFactsLockAttemptLimit is 3 in appConfig, so 2 attempts should still show the page + when(mockLockService.getBarsAttemptsUsed(any())) + .thenReturn(Future.successful(2)) + + callAuthorised(testController.show) { result => + status(result) mustBe OK + } + } + + "redirect to ThirdAttemptLockout when attempts used is at or above the lockout limit" in new Setup { + when(mockLockService.getBarsAttemptsUsed(any())) + .thenReturn(Future.successful(3)) // >= appConfig.knownFactsLockAttemptLimit (3) + + callAuthorised(testController.show) { result => + status(result) mustBe SEE_OTHER + redirectLocation(result) mustBe Some(controllers.errors.routes.ThirdAttemptLockoutController.show.url) + } + } + + "redirect to ThirdAttemptLockout when attempts used exceeds the lockout limit" in new Setup { + when(mockLockService.getBarsAttemptsUsed(any())) + .thenReturn(Future.successful(5)) + + callAuthorised(testController.show) { result => + status(result) mustBe SEE_OTHER + redirectLocation(result) mustBe Some(controllers.errors.routes.ThirdAttemptLockoutController.show.url) + } + } + } +} + diff --git a/test/controllers/errors/ThirdAttemptLockoutControllerSpec.scala b/test/controllers/errors/ThirdAttemptLockoutControllerSpec.scala new file mode 100644 index 000000000..1d5ac5965 --- /dev/null +++ b/test/controllers/errors/ThirdAttemptLockoutControllerSpec.scala @@ -0,0 +1,53 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers.errors + +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito._ +import play.api.test.FakeRequest +import testHelpers.{ControllerSpec, FutureAssertions} +import views.html.errors.ThirdAttemptLockoutPage + +import scala.concurrent.Future + +class ThirdAttemptLockoutControllerSpec extends ControllerSpec with FutureAssertions { + + val view: ThirdAttemptLockoutPage = app.injector.instanceOf[ThirdAttemptLockoutPage] + + trait Setup { + val testController = new ThirdAttemptLockoutController( + messagesControllerComponents, + view, + mockAuthConnector + ) + + // ThirdAttemptLockoutController uses AuthorisedFunctions which calls authConnector.authorise + // directly, so we stub mockAuthConnector (uk.gov.hmrc.auth.core.AuthConnector) rather than + // mockAuthClientConnector. + when(mockAuthConnector.authorise[Unit](any(), any())(any(), any())) + .thenReturn(Future.successful(())) + } + + "show" should { + "return 200 and render the lockout page" in new Setup { + val result = testController.show()(FakeRequest()) + status(result) mustBe OK + contentType(result) mustBe Some("text/html") + } + } +} + diff --git a/test/services/LockServiceSpec.scala b/test/services/LockServiceSpec.scala new file mode 100644 index 000000000..bd354b5c9 --- /dev/null +++ b/test/services/LockServiceSpec.scala @@ -0,0 +1,102 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package services + +import config.FrontendAppConfig +import org.mockito.ArgumentMatchers.{any, eq => eqTo} +import org.mockito.Mockito._ +import org.scalatestplus.mockito.MockitoSugar +import org.scalatestplus.play.PlaySpec +import play.api.test.{DefaultAwaitTimeout, FutureAwaits} +import repositories.UserLockRepository + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future + +class LockServiceSpec extends PlaySpec with MockitoSugar with FutureAwaits with DefaultAwaitTimeout { + + val mockUserLockRepository: UserLockRepository = mock[UserLockRepository] + val mockAppConfig: FrontendAppConfig = mock[FrontendAppConfig] + + val registrationId = "reg-123" + + trait Setup { + val service: LockService = new LockService(mockUserLockRepository, mockAppConfig) + } + + // ---- getBarsAttemptsUsed ---- + + "getBarsAttemptsUsed" should { + "return the number of failed attempts from the repository" in new Setup { + when(mockUserLockRepository.getFailedAttempts(eqTo(registrationId))) + .thenReturn(Future.successful(2)) + + await(service.getBarsAttemptsUsed(registrationId)) mustBe 2 + } + + "return 0 when there are no recorded attempts" in new Setup { + when(mockUserLockRepository.getFailedAttempts(eqTo(registrationId))) + .thenReturn(Future.successful(0)) + + await(service.getBarsAttemptsUsed(registrationId)) mustBe 0 + } + } + + // ---- incrementBarsAttempts ---- + + "incrementBarsAttempts" should { + "return the new total number of failed attempts after incrementing" in new Setup { + when(mockUserLockRepository.updateAttempts(eqTo(registrationId))) + .thenReturn(Future.successful(Map("user" -> 1))) + + await(service.incrementBarsAttempts(registrationId)) mustBe 1 + } + + "return 0 if the repository map does not contain the 'user' key" in new Setup { + when(mockUserLockRepository.updateAttempts(eqTo(registrationId))) + .thenReturn(Future.successful(Map.empty[String, Int])) + + await(service.incrementBarsAttempts(registrationId)) mustBe 0 + } + + "return 3 on the third failed attempt" in new Setup { + when(mockUserLockRepository.updateAttempts(eqTo(registrationId))) + .thenReturn(Future.successful(Map("user" -> 3))) + + await(service.incrementBarsAttempts(registrationId)) mustBe 3 + } + } + + // ---- isBarsLocked ---- + + "isBarsLocked" should { + "return true when the user is locked in the repository" in new Setup { + when(mockUserLockRepository.isUserLocked(eqTo(registrationId))) + .thenReturn(Future.successful(true)) + + await(service.isBarsLocked(registrationId)) mustBe true + } + + "return false when the user is not locked in the repository" in new Setup { + when(mockUserLockRepository.isUserLocked(eqTo(registrationId))) + .thenReturn(Future.successful(false)) + + await(service.isBarsLocked(registrationId)) mustBe false + } + } +} + diff --git a/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala b/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala index a2768174f..02eea18f8 100644 --- a/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala +++ b/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala @@ -27,14 +27,15 @@ class HasCompanyBankAccountViewSpec extends VatRegViewSpec { implicit val doc: Document = Jsoup.parse(view(HasCompanyBankAccountForm.form).body) lazy val view: HasCompanyBankAccountView = app.injector.instanceOf[HasCompanyBankAccountView] - val heading = "Are you able to provide bank or building society account details for the business?" + val heading = "Can you provide bank or building society details for VAT repayments to the business?" val title = s"$heading - Register for VAT - GOV.UK" - val para = "If we owe the business money, we can repay this directly into your bank by BACS. This is faster and more secure than HMRC cheques." - val para2 = "The account does not have to be a dedicated business account but it must be:" - val bullet1 = "separate from a personal account" - val bullet2 = "in the name of the registered person or company" - val bullet3 = "in the UK" - val bullet4 = "able to receive BACS payments" + val para = "If HMRC owes the business money, it will repay this directly to your account." + val para2 = "BACS is normally used for VAT repayments because this is a faster and more secure payment method than a cheque." + val para3 = "The account you select to receive VAT repayments must be:" + val bullet1 = "Used only for this business" + val bullet2 = "In the name of the individual or company registering for VAT" + val bullet3 = "Based in the UK" + val bullet4 = "Able to receive BACS payments" val yes = "Yes" val no = "No" val continue = "Save and continue" @@ -55,6 +56,7 @@ class HasCompanyBankAccountViewSpec extends VatRegViewSpec { "have correct text" in new ViewSetup { doc.para(1) mustBe Some(para) doc.para(2) mustBe Some(para2) + doc.para(3) mustBe Some(para3) } "have correct bullets" in new ViewSetup { From 3dc17485bbe7dc2019093276d3f1cc4a02292568 Mon Sep 17 00:00:00 2001 From: Hugo Greenwood <183600681+HG-Accenture@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:47:48 +0000 Subject: [PATCH 17/27] Role back headings and paragraphs in bank details view spec --- .../HasCompanyBankAccountViewSpec.scala | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala b/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala index 02eea18f8..a2768174f 100644 --- a/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala +++ b/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala @@ -27,15 +27,14 @@ class HasCompanyBankAccountViewSpec extends VatRegViewSpec { implicit val doc: Document = Jsoup.parse(view(HasCompanyBankAccountForm.form).body) lazy val view: HasCompanyBankAccountView = app.injector.instanceOf[HasCompanyBankAccountView] - val heading = "Can you provide bank or building society details for VAT repayments to the business?" + val heading = "Are you able to provide bank or building society account details for the business?" val title = s"$heading - Register for VAT - GOV.UK" - val para = "If HMRC owes the business money, it will repay this directly to your account." - val para2 = "BACS is normally used for VAT repayments because this is a faster and more secure payment method than a cheque." - val para3 = "The account you select to receive VAT repayments must be:" - val bullet1 = "Used only for this business" - val bullet2 = "In the name of the individual or company registering for VAT" - val bullet3 = "Based in the UK" - val bullet4 = "Able to receive BACS payments" + val para = "If we owe the business money, we can repay this directly into your bank by BACS. This is faster and more secure than HMRC cheques." + val para2 = "The account does not have to be a dedicated business account but it must be:" + val bullet1 = "separate from a personal account" + val bullet2 = "in the name of the registered person or company" + val bullet3 = "in the UK" + val bullet4 = "able to receive BACS payments" val yes = "Yes" val no = "No" val continue = "Save and continue" @@ -56,7 +55,6 @@ class HasCompanyBankAccountViewSpec extends VatRegViewSpec { "have correct text" in new ViewSetup { doc.para(1) mustBe Some(para) doc.para(2) mustBe Some(para2) - doc.para(3) mustBe Some(para3) } "have correct bullets" in new ViewSetup { From e6d04119861a124df79cdd3da7d8dda855e0ebf2 Mon Sep 17 00:00:00 2001 From: HG-Accenture <183600681+HG-Accenture@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:10:14 +0000 Subject: [PATCH 18/27] Add lock service routes --- conf/app.routes | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/conf/app.routes b/conf/app.routes index 039d9feb2..9bc0cb149 100644 --- a/conf/app.routes +++ b/conf/app.routes @@ -635,3 +635,7 @@ POST /partner/:index/partner-telephone controller ## Partner Email Address Page GET /partner/:index/email-address controllers.partners.PartnerCaptureEmailAddressController.show(index: Int) POST /partner/:index/email-address controllers.partners.PartnerCaptureEmailAddressController.submit(index: Int) + +# Lockout screens +GET /failed-third-attempt controllers.errors.ThirdAttemptLockoutController.show +GET /failed-attempt controllers.bankdetails.AccountDetailsNotVerified.show \ No newline at end of file From e59c29d4595c824574bdaa451dceba4edbc413bf Mon Sep 17 00:00:00 2001 From: HG-Accenture <183600681+HG-Accenture@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:52:25 +0000 Subject: [PATCH 19/27] Rebase --- .../bankdetails/CheckBankDetailsController.scala | 14 ++++++++++++-- .../UkBankAccountDetailsController.scala | 4 ++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/app/controllers/bankdetails/CheckBankDetailsController.scala b/app/controllers/bankdetails/CheckBankDetailsController.scala index 3551eab0d..3a2f118de 100644 --- a/app/controllers/bankdetails/CheckBankDetailsController.scala +++ b/app/controllers/bankdetails/CheckBankDetailsController.scala @@ -25,7 +25,7 @@ import models.bars.BankAccountDetailsSessionFormat import play.api.Configuration import play.api.libs.json.Format import play.api.mvc.{Action, AnyContent} -import services.{BankAccountDetailsService, SessionService} +import services.{BankAccountDetailsService,LockService, SessionService} import uk.gov.hmrc.crypto.SymmetricCryptoFactory import views.html.bankdetails.CheckBankDetailsView @@ -36,6 +36,7 @@ class CheckBankDetailsController @Inject() ( val authConnector: AuthClientConnector, val bankAccountDetailsService: BankAccountDetailsService, val sessionService: SessionService, + val lockService: LockService, configuration: Configuration, view: CheckBankDetailsView )(implicit appConfig: FrontendAppConfig, val executionContext: ExecutionContext, baseControllerComponents: BaseControllerComponents) @@ -69,7 +70,16 @@ class CheckBankDetailsController @Inject() ( case (Some(accountDetails), Some(accountType)) => bankAccountDetailsService.saveEnteredBankAccountDetails(accountDetails, Some(accountType)).map { case true => Redirect(controllers.routes.TaskListController.show.url) - case false => Redirect(routes.UkBankAccountDetailsController.show) + case false => + lockService.incrementBarsAttempts(profile.registrationId).map { attempts => + if (attempts >= appConfig.knownFactsLockAttemptLimit) { + Redirect(controllers.errors.routes.ThirdAttemptLockoutController.show) + } else { + Redirect(controllers.bankdetails.routes.AccountDetailsNotVerified.show) + } + } + Redirect(routes.UkBankAccountDetailsController.show) + } case _ => Future.successful(Redirect(routes.HasBankAccountController.show)) } diff --git a/app/controllers/bankdetails/UkBankAccountDetailsController.scala b/app/controllers/bankdetails/UkBankAccountDetailsController.scala index 243bb08e2..d598ad66a 100644 --- a/app/controllers/bankdetails/UkBankAccountDetailsController.scala +++ b/app/controllers/bankdetails/UkBankAccountDetailsController.scala @@ -61,9 +61,9 @@ class UkBankAccountDetailsController @Inject() ( val newBarsForm = EnterBankAccountDetailsForm.form sessionService.fetchAndGet[BankAccountDetails](sessionKey).map { case Some(details) => Ok(newBarsView(newBarsForm.fill(details))) - case None => Ok(newBarsView(newBarsForm)) + case None => Ok(newBarsView(newBarsForm)) } - } + } } From 4a7d805cbb02a58cdaebc9c1202b5fe8d583c167 Mon Sep 17 00:00:00 2001 From: HG-Accenture <183600681+HG-Accenture@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:41:26 +0000 Subject: [PATCH 20/27] Lock service --- .../HasCompanyBankAccountViewSpec.scala | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala b/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala index a2768174f..02eea18f8 100644 --- a/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala +++ b/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala @@ -27,14 +27,15 @@ class HasCompanyBankAccountViewSpec extends VatRegViewSpec { implicit val doc: Document = Jsoup.parse(view(HasCompanyBankAccountForm.form).body) lazy val view: HasCompanyBankAccountView = app.injector.instanceOf[HasCompanyBankAccountView] - val heading = "Are you able to provide bank or building society account details for the business?" + val heading = "Can you provide bank or building society details for VAT repayments to the business?" val title = s"$heading - Register for VAT - GOV.UK" - val para = "If we owe the business money, we can repay this directly into your bank by BACS. This is faster and more secure than HMRC cheques." - val para2 = "The account does not have to be a dedicated business account but it must be:" - val bullet1 = "separate from a personal account" - val bullet2 = "in the name of the registered person or company" - val bullet3 = "in the UK" - val bullet4 = "able to receive BACS payments" + val para = "If HMRC owes the business money, it will repay this directly to your account." + val para2 = "BACS is normally used for VAT repayments because this is a faster and more secure payment method than a cheque." + val para3 = "The account you select to receive VAT repayments must be:" + val bullet1 = "Used only for this business" + val bullet2 = "In the name of the individual or company registering for VAT" + val bullet3 = "Based in the UK" + val bullet4 = "Able to receive BACS payments" val yes = "Yes" val no = "No" val continue = "Save and continue" @@ -55,6 +56,7 @@ class HasCompanyBankAccountViewSpec extends VatRegViewSpec { "have correct text" in new ViewSetup { doc.para(1) mustBe Some(para) doc.para(2) mustBe Some(para2) + doc.para(3) mustBe Some(para3) } "have correct bullets" in new ViewSetup { From 158b7a54608cd57ddfd163ddcd6ff9c569337f6f Mon Sep 17 00:00:00 2001 From: Hugo Greenwood <183600681+HG-Accenture@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:47:48 +0000 Subject: [PATCH 21/27] Role back headings and paragraphs in bank details view spec --- .../HasCompanyBankAccountViewSpec.scala | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala b/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala index 02eea18f8..a2768174f 100644 --- a/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala +++ b/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala @@ -27,15 +27,14 @@ class HasCompanyBankAccountViewSpec extends VatRegViewSpec { implicit val doc: Document = Jsoup.parse(view(HasCompanyBankAccountForm.form).body) lazy val view: HasCompanyBankAccountView = app.injector.instanceOf[HasCompanyBankAccountView] - val heading = "Can you provide bank or building society details for VAT repayments to the business?" + val heading = "Are you able to provide bank or building society account details for the business?" val title = s"$heading - Register for VAT - GOV.UK" - val para = "If HMRC owes the business money, it will repay this directly to your account." - val para2 = "BACS is normally used for VAT repayments because this is a faster and more secure payment method than a cheque." - val para3 = "The account you select to receive VAT repayments must be:" - val bullet1 = "Used only for this business" - val bullet2 = "In the name of the individual or company registering for VAT" - val bullet3 = "Based in the UK" - val bullet4 = "Able to receive BACS payments" + val para = "If we owe the business money, we can repay this directly into your bank by BACS. This is faster and more secure than HMRC cheques." + val para2 = "The account does not have to be a dedicated business account but it must be:" + val bullet1 = "separate from a personal account" + val bullet2 = "in the name of the registered person or company" + val bullet3 = "in the UK" + val bullet4 = "able to receive BACS payments" val yes = "Yes" val no = "No" val continue = "Save and continue" @@ -56,7 +55,6 @@ class HasCompanyBankAccountViewSpec extends VatRegViewSpec { "have correct text" in new ViewSetup { doc.para(1) mustBe Some(para) doc.para(2) mustBe Some(para2) - doc.para(3) mustBe Some(para3) } "have correct bullets" in new ViewSetup { From 3fd3f5e0aa5dcccf8b02a4890812dcfa6700f409 Mon Sep 17 00:00:00 2001 From: mi-aspectratio <254715997+mi-aspectratio@users.noreply.github.com> Date: Tue, 17 Mar 2026 11:40:50 +0000 Subject: [PATCH 22/27] feat: lockout pages --- app/config/FrontendAppConfig.scala | 7 -- .../AccountDetailsNotVerified.scala | 54 --------- .../AccountDetailsNotVerifiedController.scala | 47 ++++++++ .../CheckBankDetailsController.scala | 32 +++--- .../UkBankAccountDetailsController.scala | 14 +-- ...ala => BankDetailsLockoutController.scala} | 25 ++-- app/repositories/BarsLockRepository.scala | 88 ++++++++++++++ app/repositories/UserLockRepository.scala | 108 ------------------ app/services/LockService.scala | 39 ++----- .../AccountDetailsNotVerifiedView.scala.html | 39 +++---- ...html => BankDetailsLockoutPage.scala.html} | 20 ++-- conf/app.routes | 10 +- conf/messages | 29 +++-- ...untDetailsNotVerifiedControllerISpec.scala | 29 ++--- .../CheckBankDetailsControllerISpec.scala | 4 +- it/test/support/AppAndStubs.scala | 7 +- ...=> BankDetailsLockoutControllerSpec.scala} | 11 +- test/services/LockServiceSpec.scala | 35 +++--- 18 files changed, 269 insertions(+), 329 deletions(-) delete mode 100644 app/controllers/bankdetails/AccountDetailsNotVerified.scala create mode 100644 app/controllers/bankdetails/AccountDetailsNotVerifiedController.scala rename app/controllers/errors/{ThirdAttemptLockoutController.scala => BankDetailsLockoutController.scala} (57%) create mode 100644 app/repositories/BarsLockRepository.scala delete mode 100644 app/repositories/UserLockRepository.scala rename app/views/errors/{ThirdAttemptLockoutPage.scala.html => BankDetailsLockoutPage.scala.html} (65%) rename test/controllers/bankdetails/AccountDetailsNotVerifiedSpec.scala => it/test/controllers/bankdetails/AccountDetailsNotVerifiedControllerISpec.scala (71%) rename test/controllers/errors/{ThirdAttemptLockoutControllerSpec.scala => BankDetailsLockoutControllerSpec.scala} (79%) diff --git a/app/config/FrontendAppConfig.scala b/app/config/FrontendAppConfig.scala index 1d1c93196..82dc01cb6 100644 --- a/app/config/FrontendAppConfig.scala +++ b/app/config/FrontendAppConfig.scala @@ -45,13 +45,6 @@ class FrontendAppConfig @Inject()(val servicesConfig: ServicesConfig, runModeCon lazy val eligibilityQuestionUrl: String = loadConfig("microservice.services.vat-registration-eligibility-frontend.question") implicit val appConfig: FrontendAppConfig = this - lazy val ttlLockSeconds:Int = 86400 - lazy val knownFactsLockAttemptLimit:Int = 3 - lazy val isKnownFactsCheckEnabled:Boolean = true - - - - private lazy val thresholdString: String = runModeConfiguration.get[ConfigList]("vat-threshold").render(ConfigRenderOptions.concise()) lazy val thresholds: Seq[VatThreshold] = Json.parse(thresholdString).as[List[VatThreshold]] diff --git a/app/controllers/bankdetails/AccountDetailsNotVerified.scala b/app/controllers/bankdetails/AccountDetailsNotVerified.scala deleted file mode 100644 index 6a6884afb..000000000 --- a/app/controllers/bankdetails/AccountDetailsNotVerified.scala +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2026 HM Revenue & Customs - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package controllers.bankdetails - -import config.{AuthClientConnector, BaseControllerComponents, FrontendAppConfig} -import controllers.BaseController -import play.api.data.Form -import play.api.data.Forms.{boolean, single} -import play.api.mvc.{Action, AnyContent} -import services.{LockService, SessionService} -import views.html.bankdetails.AccountDetailsNotVerifiedView - -import javax.inject.Inject -import scala.concurrent.ExecutionContext - -class AccountDetailsNotVerified @Inject()(val authConnector: AuthClientConnector, - val sessionService: SessionService, - lockService: LockService, - view: AccountDetailsNotVerifiedView) - (implicit appConfig: FrontendAppConfig, - val executionContext: ExecutionContext, - baseControllerComponents: BaseControllerComponents) extends BaseController { - - private val AttemptForm: Form[Boolean] = Form(single("value" -> boolean)) - - def show: Action[AnyContent] = isAuthenticatedWithProfile { - implicit request => implicit profile => - lockService.getBarsAttemptsUsed(profile.registrationId).map { attemptsUsed => - if (attemptsUsed >= appConfig.knownFactsLockAttemptLimit) { - Redirect(controllers.errors.routes.ThirdAttemptLockoutController.show) - } else { - val formWithAttempts = AttemptForm.bind(Map( - "value" -> "true", - "attempts" -> attemptsUsed.toString - )) - Ok(view(formWithAttempts)) - } - } - } -} diff --git a/app/controllers/bankdetails/AccountDetailsNotVerifiedController.scala b/app/controllers/bankdetails/AccountDetailsNotVerifiedController.scala new file mode 100644 index 000000000..6fea4a8cd --- /dev/null +++ b/app/controllers/bankdetails/AccountDetailsNotVerifiedController.scala @@ -0,0 +1,47 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers.bankdetails + +import config.{AuthClientConnector, BaseControllerComponents, FrontendAppConfig} +import controllers.BaseController +import play.api.mvc.{Action, AnyContent} +import services.{LockService, SessionService} +import views.html.bankdetails.AccountDetailsNotVerifiedView + +import javax.inject.Inject +import scala.concurrent.{ExecutionContext, Future} + +class AccountDetailsNotVerifiedController @Inject()( + val authConnector: AuthClientConnector, + val sessionService: SessionService, + lockService: LockService, + view: AccountDetailsNotVerifiedView + )(implicit appConfig: FrontendAppConfig, + val executionContext: ExecutionContext, + baseControllerComponents: BaseControllerComponents) extends BaseController { + + def show: Action[AnyContent] = isAuthenticatedWithProfile { + implicit request => implicit profile => + lockService.isBarsLocked(profile.registrationId).flatMap { + case true => Future.successful(Redirect(controllers.errors.routes.BankDetailsLockoutController.show)) + case false => + lockService.getBarsAttemptsUsed(profile.registrationId).map { attemptsUsed => + Ok(view(attemptsUsed)) + } + } + } +} \ No newline at end of file diff --git a/app/controllers/bankdetails/CheckBankDetailsController.scala b/app/controllers/bankdetails/CheckBankDetailsController.scala index 3a2f118de..9056ae01f 100644 --- a/app/controllers/bankdetails/CheckBankDetailsController.scala +++ b/app/controllers/bankdetails/CheckBankDetailsController.scala @@ -25,7 +25,7 @@ import models.bars.BankAccountDetailsSessionFormat import play.api.Configuration import play.api.libs.json.Format import play.api.mvc.{Action, AnyContent} -import services.{BankAccountDetailsService,LockService, SessionService} +import services.{BankAccountDetailsService, LockService, SessionService} import uk.gov.hmrc.crypto.SymmetricCryptoFactory import views.html.bankdetails.CheckBankDetailsView @@ -50,11 +50,16 @@ class CheckBankDetailsController @Inject() ( private val sessionKey = "bankAccountDetails" - def show: Action[AnyContent] = isAuthenticated { implicit request => + def show: Action[AnyContent] = isAuthenticatedWithProfile { implicit request => implicit profile => if (isEnabled(UseNewBarsVerify)) { - sessionService.fetchAndGet[BankAccountDetails](sessionKey).map { - case Some(details) => Ok(view(details)) - case None => Redirect(routes.HasBankAccountController.show) + lockService.isBarsLocked(profile.registrationId).flatMap { + case true => Future.successful(Redirect(controllers.errors.routes.BankDetailsLockoutController.show)) + case false => + sessionService.fetchAndGet[BankAccountDetails](sessionKey).map { + case Some(details) => Ok(view(details)) + case None => Redirect(routes.HasBankAccountController.show) + } + } } else { Future.successful(Redirect(routes.HasBankAccountController.show)) @@ -68,18 +73,17 @@ class CheckBankDetailsController @Inject() ( bankAccount <- bankAccountDetailsService.getBankAccount result <- (details, bankAccount.flatMap(_.bankAccountType)) match { case (Some(accountDetails), Some(accountType)) => - bankAccountDetailsService.saveEnteredBankAccountDetails(accountDetails, Some(accountType)).map { - case true => Redirect(controllers.routes.TaskListController.show.url) + bankAccountDetailsService.saveEnteredBankAccountDetails(accountDetails, Some(accountType)).flatMap { + case true => Future.successful(Redirect(controllers.routes.TaskListController.show.url)) case false => - lockService.incrementBarsAttempts(profile.registrationId).map { attempts => - if (attempts >= appConfig.knownFactsLockAttemptLimit) { - Redirect(controllers.errors.routes.ThirdAttemptLockoutController.show) - } else { - Redirect(controllers.bankdetails.routes.AccountDetailsNotVerified.show) + lockService.incrementBarsAttempts(profile.registrationId).flatMap { _ => + lockService.isBarsLocked(profile.registrationId).map { + case true => + Redirect(controllers.errors.routes.BankDetailsLockoutController.show) + case false => + Redirect(controllers.bankdetails.routes.AccountDetailsNotVerifiedController.show) } } - Redirect(routes.UkBankAccountDetailsController.show) - } case _ => Future.successful(Redirect(routes.HasBankAccountController.show)) } diff --git a/app/controllers/bankdetails/UkBankAccountDetailsController.scala b/app/controllers/bankdetails/UkBankAccountDetailsController.scala index d598ad66a..e6b1aee45 100644 --- a/app/controllers/bankdetails/UkBankAccountDetailsController.scala +++ b/app/controllers/bankdetails/UkBankAccountDetailsController.scala @@ -27,7 +27,7 @@ import models.bars.BankAccountDetailsSessionFormat import play.api.libs.json.Format import play.api.mvc.{Action, AnyContent} import play.api.Configuration -import services.{BankAccountDetailsService,LockService, SessionService} +import services.{BankAccountDetailsService, LockService, SessionService} import uk.gov.hmrc.crypto.SymmetricCryptoFactory import views.html.bankdetails.{EnterBankAccountDetails, EnterCompanyBankAccountDetails} @@ -55,26 +55,22 @@ class UkBankAccountDetailsController @Inject() ( def show: Action[AnyContent] = isAuthenticatedWithProfile { implicit request => implicit profile => if (isEnabled(UseNewBarsVerify)) { - lockService.getBarsAttemptsUsed(profile.registrationId).map(_ >= appConfig.knownFactsLockAttemptLimit).flatMap { - case true => Future.successful(Redirect(controllers.errors.routes.ThirdAttemptLockoutController.show)) + lockService.isBarsLocked(profile.registrationId).flatMap { + case true => Future.successful(Redirect(controllers.errors.routes.BankDetailsLockoutController.show)) case false => val newBarsForm = EnterBankAccountDetailsForm.form sessionService.fetchAndGet[BankAccountDetails](sessionKey).map { case Some(details) => Ok(newBarsView(newBarsForm.fill(details))) - case None => Ok(newBarsView(newBarsForm)) + case None => Ok(newBarsView(newBarsForm)) } } - } - - - else { + } else { for { bankDetails <- bankAccountDetailsService.getBankAccount filledForm = bankDetails.flatMap(_.details).fold(enterBankAccountDetailsForm)(enterBankAccountDetailsForm.fill) } yield Ok(oldView(filledForm)) } } - def submit: Action[AnyContent] = isAuthenticatedWithProfile { implicit request => implicit profile => if (isEnabled(UseNewBarsVerify)) { val newBarsForm = EnterBankAccountDetailsForm.form diff --git a/app/controllers/errors/ThirdAttemptLockoutController.scala b/app/controllers/errors/BankDetailsLockoutController.scala similarity index 57% rename from app/controllers/errors/ThirdAttemptLockoutController.scala rename to app/controllers/errors/BankDetailsLockoutController.scala index 2255ae189..ff7489958 100644 --- a/app/controllers/errors/ThirdAttemptLockoutController.scala +++ b/app/controllers/errors/BankDetailsLockoutController.scala @@ -16,27 +16,22 @@ package controllers.errors - +import config.FrontendAppConfig import play.api.mvc.{Action, AnyContent, MessagesControllerComponents} import uk.gov.hmrc.auth.core.{AuthConnector, AuthorisedFunctions} -import config.FrontendAppConfig import uk.gov.hmrc.play.bootstrap.frontend.controller.FrontendController -import services.SessionService -import views.html.errors.ThirdAttemptLockoutPage +import views.html.errors.BankDetailsLockoutPage import javax.inject.{Inject, Singleton} -import scala.concurrent.{ExecutionContext, Future} +import scala.concurrent.Future @Singleton -class ThirdAttemptLockoutController @Inject()(mcc: MessagesControllerComponents, - view: ThirdAttemptLockoutPage, - val authConnector: AuthConnector - )(implicit appConfig: FrontendAppConfig, ec: ExecutionContext) extends FrontendController(mcc) with AuthorisedFunctions { +class BankDetailsLockoutController @Inject()(mcc: MessagesControllerComponents, view: BankDetailsLockoutPage, val authConnector: AuthConnector)( + implicit appConfig: FrontendAppConfig) + extends FrontendController(mcc) + with AuthorisedFunctions { - def show(): Action[AnyContent] = Action.async { - implicit request => - authorised() { - Future.successful(Ok(view())) - } + def show: Action[AnyContent] = Action.async { implicit request => + Future.successful(Ok(view())) } -} \ No newline at end of file +} diff --git a/app/repositories/BarsLockRepository.scala b/app/repositories/BarsLockRepository.scala new file mode 100644 index 000000000..d4936f8eb --- /dev/null +++ b/app/repositories/BarsLockRepository.scala @@ -0,0 +1,88 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package repositories + +import models.Lock +import models.Lock._ +import org.mongodb.scala.model._ +import org.mongodb.scala.model.Indexes.ascending +import play.api.libs.json._ +import uk.gov.hmrc.mongo.MongoComponent +import uk.gov.hmrc.mongo.play.json.PlayMongoRepository + +import java.time.Instant +import java.util.concurrent.TimeUnit +import javax.inject.{Inject, Singleton} +import scala.concurrent.{ExecutionContext, Future} + +@Singleton +class BarsLockRepository @Inject() ( + mongoComponent: MongoComponent +)(implicit ec: ExecutionContext) + extends PlayMongoRepository[Lock]( + collectionName = "bars-lock", + mongoComponent = mongoComponent, + domainFormat = implicitly[Format[Lock]], + indexes = Seq( + IndexModel( + keys = ascending("lastAttemptedAt"), + indexOptions = IndexOptions() + .name("BarsLockExpires") + .expireAfter(24, TimeUnit.HOURS) + ), + IndexModel( + keys = ascending("identifier"), + indexOptions = IndexOptions() + .name("BarsIdentifierIdx") + .sparse(true) + .unique(true) + ) + ), + replaceIndexes = true + ) { + + private val attemptLimit = 3 + + def getAttemptsUsed(registrationId: String): Future[Int] = + collection + .find(Filters.eq("identifier", registrationId)) + .headOption() + .map(_.map(_.failedAttempts).getOrElse(0)) + + def isLocked(registrationId: String): Future[Boolean] = + getAttemptsUsed(registrationId).map(_ >= attemptLimit) + + def recordFailedAttempt(registrationId: String): Future[Int] = { + val update = Updates.combine( + Updates.inc("failedAttempts", 1), + Updates.set("lastAttemptedAt", Instant.now()), + Updates.setOnInsert("identifier", registrationId) + ) + val options = FindOneAndUpdateOptions() + .upsert(true) + .returnDocument(ReturnDocument.AFTER) + + collection + .findOneAndUpdate( + Filters.eq("identifier", registrationId), + update, + options + ) + .toFutureOption() + .map(_.map(_.failedAttempts).getOrElse(1)) + } +} diff --git a/app/repositories/UserLockRepository.scala b/app/repositories/UserLockRepository.scala deleted file mode 100644 index f5942abc2..000000000 --- a/app/repositories/UserLockRepository.scala +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright 2026 HM Revenue & Customs - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package repositories - -import org.mongodb.scala.model.Indexes.ascending -import org.mongodb.scala.model._ -import play.api.libs.json._ -import config.FrontendAppConfig -import models.Lock -import models.Lock._ -import uk.gov.hmrc.mongo.MongoComponent -import uk.gov.hmrc.mongo.play.json.PlayMongoRepository - -import java.time.Instant -import java.util.concurrent.TimeUnit -import javax.inject.{Inject, Singleton} -import scala.concurrent.{ExecutionContext, Future} - -@Singleton -class UserLockRepository @Inject()( - mongoComponent: MongoComponent, - appConfig: FrontendAppConfig - )(implicit ec: ExecutionContext) extends PlayMongoRepository[Lock]( - collectionName = "user-lock", - mongoComponent = mongoComponent, - domainFormat = implicitly[Format[Lock]], - indexes = Seq( - IndexModel( - keys = ascending("lastAttemptedAt"), - indexOptions = IndexOptions() - .name("CVEInvalidDataLockExpires") - .expireAfter(appConfig.ttlLockSeconds, TimeUnit.SECONDS) - ), - IndexModel( - keys = ascending("identifier"), - indexOptions = IndexOptions() - .name("IdentifierIdx") - .sparse(true) - .unique(true) - ) - ), - replaceIndexes = true -) { - - def getFailedAttempts(identifier: String): Future[Int] = - collection - .find(Filters.eq("identifier", identifier)) - .headOption() - .map(_.map(_.failedAttempts).getOrElse(0)) - - def isUserLocked(userId: String): Future[Boolean] = { - collection - .find(Filters.in("identifier", userId)) - .toFuture() - .map { _.exists { _.failedAttempts >= appConfig.knownFactsLockAttemptLimit }} - } - - def updateAttempts(userId: String): Future[Map[String, Int]] = { - def updateAttemptsForLockWith(identifier: String): Future[Lock] = { - collection - .find(Filters.eq("identifier", identifier)) - .headOption() - .flatMap { - case Some(existingLock) => - val newLock = existingLock.copy( - failedAttempts = existingLock.failedAttempts + 1, - lastAttemptedAt = Instant.now() - ) - collection.replaceOne( - Filters.and( - Filters.eq("identifier", identifier) - ), - newLock - ) - .toFuture() - .map(_ => newLock) - case _ => - val newLock = Lock(identifier, 1, Instant.now) - collection.insertOne(newLock) - .toFuture() - .map(_ => newLock) - } - } - val updateUserLock = updateAttemptsForLockWith(userId) - - for { - userLock <- updateUserLock - } yield { - Map( - "user" -> userLock.failedAttempts - ) - } - } -} \ No newline at end of file diff --git a/app/services/LockService.scala b/app/services/LockService.scala index 49f67ad24..39aa95f76 100644 --- a/app/services/LockService.scala +++ b/app/services/LockService.scala @@ -16,47 +16,22 @@ package services - -import play.api.mvc.Result -import play.api.mvc.Results.Redirect -import config.FrontendAppConfig -import controllers.errors -import repositories.UserLockRepository +import repositories.BarsLockRepository import utils.LoggingUtil import javax.inject.{Inject, Singleton} -import scala.concurrent.{ExecutionContext, Future} +import scala.concurrent.Future @Singleton -class LockService @Inject()(userLockRepository: UserLockRepository, - config: FrontendAppConfig)(implicit ec: ExecutionContext) extends LoggingUtil{ - - - - def updateAttempts(userId: String): Future[Map[String, Int]] = { - if (config.isKnownFactsCheckEnabled) { - userLockRepository.updateAttempts(userId) - } else { - Future.successful(Map.empty) - } - } - - def isJourneyLocked(userId: String): Future[Boolean] = { - if (config.isKnownFactsCheckEnabled) { - userLockRepository.isUserLocked(userId) - } else { - Future.successful(false) - } - } - - // ---- BARs bank account lock methods ---- +class LockService @Inject() (barsLockRepository: BarsLockRepository) extends LoggingUtil { def getBarsAttemptsUsed(registrationId: String): Future[Int] = - userLockRepository.getFailedAttempts(registrationId) + barsLockRepository.getAttemptsUsed(registrationId) def incrementBarsAttempts(registrationId: String): Future[Int] = - userLockRepository.updateAttempts(registrationId).map(_.getOrElse("user", 0)) + barsLockRepository.recordFailedAttempt(registrationId) def isBarsLocked(registrationId: String): Future[Boolean] = - userLockRepository.isUserLocked(registrationId) + barsLockRepository.isLocked(registrationId) + } diff --git a/app/views/bankdetails/AccountDetailsNotVerifiedView.scala.html b/app/views/bankdetails/AccountDetailsNotVerifiedView.scala.html index 82bebb867..8a57716a6 100644 --- a/app/views/bankdetails/AccountDetailsNotVerifiedView.scala.html +++ b/app/views/bankdetails/AccountDetailsNotVerifiedView.scala.html @@ -15,43 +15,42 @@ *@ @import config.FrontendAppConfig -@import play.api.data.Form @import play.api.i18n.Messages @import play.api.mvc.Request -@import uk.gov.hmrc.govukfrontend.views.html.components._ @this( - layout: layouts.layout, - h1: components.h1, - p: components.p, - formWithCSRF: FormWithCSRF, - errorSummary: components.errorSummary, + layout: layouts.layout, + h1: components.h1, + p: components.p, + govukBackLink: GovukBackLink ) -@(form: Form[Boolean])(implicit request: Request[_], messages: Messages, appConfig: FrontendAppConfig) +@(attemptsUsed: Int)(implicit request: Request[_], messages: Messages, appConfig: FrontendAppConfig) -@layout(pageTitle = Some(title(form, messages("pages.accountDetailsCouldNotBeVerified.heading")))){ - - @errorSummary(errors = form.errors) - +@layout( + pageTitle = Some(messages("pages.accountDetailsCouldNotBeVerified.heading")), + backLink = false +) { @h1("pages.accountDetailsCouldNotBeVerified.heading") @p { - @messages("pages.accountDetailsCouldNotBeVerified.para1", 3 - form.data.get("attempts").map(_.toInt).getOrElse(0)) + @messages("pages.accountDetailsCouldNotBeVerified.text.pre") + @if(attemptsUsed == 2) { + @messages("pages.accountDetailsCouldNotBeVerified.one.remaining") + } else { + @messages("pages.accountDetailsCouldNotBeVerified.two.remaining") + } + @messages("pages.accountDetailsCouldNotBeVerified.text.post") } @p { - @Html(messages("pages.accountDetailsCouldNotBeVerified.para2", appConfig.vatTaskList)) + @Html(messages("pages.accountDetailsCouldNotBeVerified.para2", controllers.bankdetails.routes.UkBankAccountDetailsController.show.url)) } -@* - * @p { - * @messages("pages.accountDetailsCouldNotBeVerified.para2") - * } -*@ @p { - @messages("pages.accountDetailsCouldNotBeVerified.para3.bold") @messages("pages.accountDetailsCouldNotBeVerified.para3") + @messages("pages.accountDetailsCouldNotBeVerified.para3.bold") + @messages("pages.accountDetailsCouldNotBeVerified.para3") } } \ No newline at end of file diff --git a/app/views/errors/ThirdAttemptLockoutPage.scala.html b/app/views/errors/BankDetailsLockoutPage.scala.html similarity index 65% rename from app/views/errors/ThirdAttemptLockoutPage.scala.html rename to app/views/errors/BankDetailsLockoutPage.scala.html index d348775a8..41ffeba60 100644 --- a/app/views/errors/ThirdAttemptLockoutPage.scala.html +++ b/app/views/errors/BankDetailsLockoutPage.scala.html @@ -19,31 +19,31 @@ @import play.api.mvc.Request @import views.html.components._ @import views.html.layouts.layout -@import uk.gov.hmrc.govukfrontend.views.html.components._ @this(layout: layout, - h1: h1, - p: p, - govUkHeader: GovukHeader, - govukButton: GovukButton + h1: h1, + p: p, + link: link + ) @()(implicit request: Request[_], messages: Messages, appConfig: FrontendAppConfig) -@layout(Some(titleNoForm(messages("ThirdAttemptLockoutPage.heading"))), backLink = false) { +@layout(Some(titleNoForm(messages("pages.bankDetailsLockoutPage.heading"))), backLink = false) { - @h1(messages("ThirdAttemptLockoutPage.heading")) + @h1(messages("pages.bankDetailsLockoutPage.heading")) @p { - @messages("pages.ThirdAttemptLockoutPage.para1") + @messages("pages.bankDetailsLockoutPage.para1") } @p { - @messages("pages.ThirdAttemptLockoutPage.para2") + @messages("pages.bankDetailsLockoutPage.para2") } @p { - @Html(messages("pages.ThirdAttemptLockoutPage.para3", appConfig.vatTaskList)) + @link(controllers.routes.TaskListController.show.url, messages("pages.bankDetailsLockoutPage.para3.link"))@messages("pages.bankDetailsLockoutPage.para3") } + } \ No newline at end of file diff --git a/conf/app.routes b/conf/app.routes index 9bc0cb149..a8bbe3f63 100644 --- a/conf/app.routes +++ b/conf/app.routes @@ -138,6 +138,10 @@ POST /account-details controller GET /check-bank-details controllers.bankdetails.CheckBankDetailsController.show POST /check-bank-details controllers.bankdetails.CheckBankDetailsController.submit +# FAILED VERIFICATION SCREENS +GET /failed-verification controllers.bankdetails.AccountDetailsNotVerifiedController.show +GET /verification-lockout controllers.errors.BankDetailsLockoutController.show + ## VAT CORRESPONDENCE GET /vat-correspondence-language controllers.business.VatCorrespondenceController.show POST /vat-correspondence-language controllers.business.VatCorrespondenceController.submit @@ -634,8 +638,4 @@ POST /partner/:index/partner-telephone controller ## Partner Email Address Page GET /partner/:index/email-address controllers.partners.PartnerCaptureEmailAddressController.show(index: Int) -POST /partner/:index/email-address controllers.partners.PartnerCaptureEmailAddressController.submit(index: Int) - -# Lockout screens -GET /failed-third-attempt controllers.errors.ThirdAttemptLockoutController.show -GET /failed-attempt controllers.bankdetails.AccountDetailsNotVerified.show \ No newline at end of file +POST /partner/:index/email-address controllers.partners.PartnerCaptureEmailAddressController.submit(index: Int) \ No newline at end of file diff --git a/conf/messages b/conf/messages index c62bcfe0e..6fc7f658e 100644 --- a/conf/messages +++ b/conf/messages @@ -1030,6 +1030,23 @@ pages.checkBankDetails.sortCode = Sort code pages.checkBankDetails.rollNumber = Building society roll number pages.checkBankDetails.p1 = By confirming these account details, you agree the information you have provided is complete and correct. +# Account Details could not be verified +pages.accountDetailsCouldNotBeVerified.heading = We could not verify the bank details you provided +pages.accountDetailsCouldNotBeVerified.text.pre = You have +pages.accountDetailsCouldNotBeVerified.one.remaining = one more attempt +pages.accountDetailsCouldNotBeVerified.two.remaining = two more attempts +pages.accountDetailsCouldNotBeVerified.text.post = to provide your account details. +pages.accountDetailsCouldNotBeVerified.para2 = Enter your bank account or building society details again, making sure the name matches exactly as it appears on your account. +pages.accountDetailsCouldNotBeVerified.para3.bold = After 3 consecutive unsuccessful attempts, +pages.accountDetailsCouldNotBeVerified.para3 = you will need to complete your VAT registration before sending us your details. + +# Locked Out Page +pages.bankDetailsLockoutPage.heading = Account details could not be verified +pages.bankDetailsLockoutPage.para1 = We have been unable to verify the account details you supplied. +pages.bankDetailsLockoutPage.para2 = For your security, we have paused this part of the service. +pages.bankDetailsLockoutPage.para3.link = You can return to the VAT registration task list now +pages.bankDetailsLockoutPage.para3 =, and provide your account details later once your registration is confirmed. + # Main Business Activity Page pages.mainBusinessActivity.heading = Which activity is the business’s main source of income? validation.mainBusinessActivity.missing = Select the business’s main source of income @@ -2049,15 +2066,3 @@ partnerEmail.link = HMRC Privacy Notice partnerEmail.error.incorrect_format = Enter the email address in the correct format, like name@example.com partnerEmail.error.nothing_entered = Enter the email address partnerEmail.error.incorrect_length = The email address must be 132 characters or fewer - -#Account Details could not be verified -pages.accountDetailsCouldNotBeVerified.heading = We could not verify the bank details you provided -pages.accountDetailsCouldNotBeVerified.para1 = You have {0} more attempts to provide your account details. -pages.accountDetailsCouldNotBeVerified.para2 = Enter your bank account or building society details again, making sure the name matches exactly as it appears on your account. -pages.accountDetailsCouldNotBeVerified.para3.bold = After 3 consecutive unsuccessful attempts, -pages.accountDetailsCouldNotBeVerified.para3 = you will need to complete your VAT registration before sending us your details. - -ThirdAttemptLockoutPage.heading = Account details could not be verified -pages.ThirdAttemptLockoutPage.para1 = We have been unable to verify the account details you supplied. -pages.ThirdAttemptLockoutPage.para2 = For your security, we have paused this part of the service. -pages.ThirdAttemptLockoutPage.para3 = You can return to the VAT registration task list now, and provide your account details later once your registration is confirmed. \ No newline at end of file diff --git a/test/controllers/bankdetails/AccountDetailsNotVerifiedSpec.scala b/it/test/controllers/bankdetails/AccountDetailsNotVerifiedControllerISpec.scala similarity index 71% rename from test/controllers/bankdetails/AccountDetailsNotVerifiedSpec.scala rename to it/test/controllers/bankdetails/AccountDetailsNotVerifiedControllerISpec.scala index e58c58d10..a545636bd 100644 --- a/test/controllers/bankdetails/AccountDetailsNotVerifiedSpec.scala +++ b/it/test/controllers/bankdetails/AccountDetailsNotVerifiedControllerISpec.scala @@ -19,20 +19,19 @@ package controllers.bankdetails import fixtures.VatRegistrationFixture import org.mockito.ArgumentMatchers.any import org.mockito.Mockito._ -import play.api.test.FakeRequest import services.LockService import testHelpers.{ControllerSpec, FutureAssertions} import views.html.bankdetails.AccountDetailsNotVerifiedView import scala.concurrent.Future -class AccountDetailsNotVerifiedSpec extends ControllerSpec with VatRegistrationFixture with FutureAssertions { +class AccountDetailsNotVerifiedControllerISpec extends ControllerSpec with VatRegistrationFixture with FutureAssertions { val mockLockService: LockService = mock[LockService] val view: AccountDetailsNotVerifiedView = app.injector.instanceOf[AccountDetailsNotVerifiedView] trait Setup { - val testController = new AccountDetailsNotVerified( + val testController = new AccountDetailsNotVerifiedController( mockAuthClientConnector, mockSessionService, mockLockService, @@ -45,6 +44,8 @@ class AccountDetailsNotVerifiedSpec extends ControllerSpec with VatRegistrationF "show" should { "return 200 when attempts used is below the lockout limit" in new Setup { + when(mockLockService.isBarsLocked(any())) + .thenReturn(Future.successful(false)) when(mockLockService.getBarsAttemptsUsed(any())) .thenReturn(Future.successful(1)) @@ -54,7 +55,8 @@ class AccountDetailsNotVerifiedSpec extends ControllerSpec with VatRegistrationF } "return 200 when attempts used is one below the lockout limit" in new Setup { - // knownFactsLockAttemptLimit is 3 in appConfig, so 2 attempts should still show the page + when(mockLockService.isBarsLocked(any())) + .thenReturn(Future.successful(false)) when(mockLockService.getBarsAttemptsUsed(any())) .thenReturn(Future.successful(2)) @@ -63,25 +65,24 @@ class AccountDetailsNotVerifiedSpec extends ControllerSpec with VatRegistrationF } } - "redirect to ThirdAttemptLockout when attempts used is at or above the lockout limit" in new Setup { - when(mockLockService.getBarsAttemptsUsed(any())) - .thenReturn(Future.successful(3)) // >= appConfig.knownFactsLockAttemptLimit (3) + "redirect to BankDetailsLockoutController when attempts used is at or above the lockout limit" in new Setup { + when(mockLockService.isBarsLocked(any())) + .thenReturn(Future.successful(true)) callAuthorised(testController.show) { result => status(result) mustBe SEE_OTHER - redirectLocation(result) mustBe Some(controllers.errors.routes.ThirdAttemptLockoutController.show.url) + redirectLocation(result) mustBe Some(controllers.errors.routes.BankDetailsLockoutController.show.url) } } - "redirect to ThirdAttemptLockout when attempts used exceeds the lockout limit" in new Setup { - when(mockLockService.getBarsAttemptsUsed(any())) - .thenReturn(Future.successful(5)) + "redirect to BankDetailsLockoutController when attempts used exceeds the lockout limit" in new Setup { + when(mockLockService.isBarsLocked(any())) + .thenReturn(Future.successful(true)) callAuthorised(testController.show) { result => status(result) mustBe SEE_OTHER - redirectLocation(result) mustBe Some(controllers.errors.routes.ThirdAttemptLockoutController.show.url) + redirectLocation(result) mustBe Some(controllers.errors.routes.BankDetailsLockoutController.show.url) } } - } -} + }} diff --git a/it/test/controllers/bankdetails/CheckBankDetailsControllerISpec.scala b/it/test/controllers/bankdetails/CheckBankDetailsControllerISpec.scala index d6e82b50a..00d84bf33 100644 --- a/it/test/controllers/bankdetails/CheckBankDetailsControllerISpec.scala +++ b/it/test/controllers/bankdetails/CheckBankDetailsControllerISpec.scala @@ -140,7 +140,7 @@ class CheckBankDetailsControllerISpec extends ControllerISpec with ITRegistratio res.header(HeaderNames.LOCATION) mustBe Some(controllers.routes.TaskListController.show.url) } - "redirect back to UkBankAccountDetailsController when BARS verification fails" in new Setup { + "redirect to AccountDetailsNotVerifiedController when BARS verification fails" in new Setup { enable(UseNewBarsVerify) given().user .isAuthorised() @@ -162,7 +162,7 @@ class CheckBankDetailsControllerISpec extends ControllerISpec with ITRegistratio val res: WSResponse = await(buildClient(url).post(Map.empty[String, String])) res.status mustBe SEE_OTHER - res.header(HeaderNames.LOCATION) mustBe Some(routes.UkBankAccountDetailsController.show.url) + res.header(HeaderNames.LOCATION) mustBe Some(routes.AccountDetailsNotVerifiedController.show.url) } "redirect to HasBankAccountController when session is empty" in new Setup { diff --git a/it/test/support/AppAndStubs.scala b/it/test/support/AppAndStubs.scala index ad8f20b8d..991552dcf 100644 --- a/it/test/support/AppAndStubs.scala +++ b/it/test/support/AppAndStubs.scala @@ -25,11 +25,11 @@ import org.scalatestplus.play.guice.GuiceOneServerPerSuite import play.api.Application import play.api.http.HeaderNames import play.api.inject.guice.GuiceApplicationBuilder -import play.api.libs.json.{JsValue, Json, Writes} +import play.api.libs.json.{Json, JsValue, Writes} import play.api.libs.ws.{WSClient, WSRequest} import play.api.mvc.AnyContentAsFormUrlEncoded import play.api.test.FakeRequest -import repositories.SessionRepository +import repositories.{BarsLockRepository, SessionRepository} import uk.gov.hmrc.http.HeaderCarrier import uk.gov.hmrc.http.cache.client.CacheMap @@ -46,10 +46,13 @@ trait AppAndStubs extends StubUtils with GuiceOneServerPerSuite with Integration def customAwait[A](future: Future[A])(implicit timeout: Duration): A = Await.result(future, timeout) val repo: SessionRepository = app.injector.instanceOf[SessionRepository] + val barsLockRepository: BarsLockRepository = app.injector.instanceOf[BarsLockRepository] + val defaultTimeout: FiniteDuration = 2 seconds customAwait(repo.ensureIndexes())(defaultTimeout) customAwait(repo.collection.countDocuments().head())(defaultTimeout) + customAwait(barsLockRepository.collection.drop().head())(defaultTimeout) def insertCurrentProfileIntoDb(currentProfile: models.CurrentProfile, sessionId: String): Boolean = { customAwait(repo.collection.countDocuments().head())(defaultTimeout) diff --git a/test/controllers/errors/ThirdAttemptLockoutControllerSpec.scala b/test/controllers/errors/BankDetailsLockoutControllerSpec.scala similarity index 79% rename from test/controllers/errors/ThirdAttemptLockoutControllerSpec.scala rename to test/controllers/errors/BankDetailsLockoutControllerSpec.scala index 1d5ac5965..4e0173f6a 100644 --- a/test/controllers/errors/ThirdAttemptLockoutControllerSpec.scala +++ b/test/controllers/errors/BankDetailsLockoutControllerSpec.scala @@ -18,18 +18,19 @@ package controllers.errors import org.mockito.ArgumentMatchers.any import org.mockito.Mockito._ +import play.api.mvc.Result import play.api.test.FakeRequest import testHelpers.{ControllerSpec, FutureAssertions} -import views.html.errors.ThirdAttemptLockoutPage +import views.html.errors.BankDetailsLockoutPage import scala.concurrent.Future -class ThirdAttemptLockoutControllerSpec extends ControllerSpec with FutureAssertions { +class BankDetailsLockoutControllerSpec extends ControllerSpec with FutureAssertions { - val view: ThirdAttemptLockoutPage = app.injector.instanceOf[ThirdAttemptLockoutPage] + val view: BankDetailsLockoutPage = app.injector.instanceOf[BankDetailsLockoutPage] trait Setup { - val testController = new ThirdAttemptLockoutController( + val testController = new BankDetailsLockoutController( messagesControllerComponents, view, mockAuthConnector @@ -44,7 +45,7 @@ class ThirdAttemptLockoutControllerSpec extends ControllerSpec with FutureAssert "show" should { "return 200 and render the lockout page" in new Setup { - val result = testController.show()(FakeRequest()) + val result: Future[Result] = testController.show()(FakeRequest()) status(result) mustBe OK contentType(result) mustBe Some("text/html") } diff --git a/test/services/LockServiceSpec.scala b/test/services/LockServiceSpec.scala index bd354b5c9..0c7f7c249 100644 --- a/test/services/LockServiceSpec.scala +++ b/test/services/LockServiceSpec.scala @@ -22,77 +22,72 @@ import org.mockito.Mockito._ import org.scalatestplus.mockito.MockitoSugar import org.scalatestplus.play.PlaySpec import play.api.test.{DefaultAwaitTimeout, FutureAwaits} -import repositories.UserLockRepository +import repositories.BarsLockRepository import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.Future class LockServiceSpec extends PlaySpec with MockitoSugar with FutureAwaits with DefaultAwaitTimeout { - val mockUserLockRepository: UserLockRepository = mock[UserLockRepository] + val mockBarsLockRepository: BarsLockRepository = mock[BarsLockRepository] val mockAppConfig: FrontendAppConfig = mock[FrontendAppConfig] val registrationId = "reg-123" trait Setup { - val service: LockService = new LockService(mockUserLockRepository, mockAppConfig) + val service: LockService = new LockService(mockBarsLockRepository) } - // ---- getBarsAttemptsUsed ---- - "getBarsAttemptsUsed" should { "return the number of failed attempts from the repository" in new Setup { - when(mockUserLockRepository.getFailedAttempts(eqTo(registrationId))) + when(mockBarsLockRepository.getAttemptsUsed(eqTo(registrationId))) .thenReturn(Future.successful(2)) await(service.getBarsAttemptsUsed(registrationId)) mustBe 2 } "return 0 when there are no recorded attempts" in new Setup { - when(mockUserLockRepository.getFailedAttempts(eqTo(registrationId))) + when(mockBarsLockRepository.getAttemptsUsed(eqTo(registrationId))) .thenReturn(Future.successful(0)) await(service.getBarsAttemptsUsed(registrationId)) mustBe 0 } } - // ---- incrementBarsAttempts ---- "incrementBarsAttempts" should { "return the new total number of failed attempts after incrementing" in new Setup { - when(mockUserLockRepository.updateAttempts(eqTo(registrationId))) - .thenReturn(Future.successful(Map("user" -> 1))) + when(mockBarsLockRepository.recordFailedAttempt(eqTo(registrationId))) + .thenReturn(Future.successful(1)) await(service.incrementBarsAttempts(registrationId)) mustBe 1 } - "return 0 if the repository map does not contain the 'user' key" in new Setup { - when(mockUserLockRepository.updateAttempts(eqTo(registrationId))) - .thenReturn(Future.successful(Map.empty[String, Int])) + "return 1 as default if repository returns nothing" in new Setup { + when(mockBarsLockRepository.recordFailedAttempt(eqTo(registrationId))) + .thenReturn(Future.successful(1)) - await(service.incrementBarsAttempts(registrationId)) mustBe 0 + await(service.incrementBarsAttempts(registrationId)) mustBe 1 } "return 3 on the third failed attempt" in new Setup { - when(mockUserLockRepository.updateAttempts(eqTo(registrationId))) - .thenReturn(Future.successful(Map("user" -> 3))) + when(mockBarsLockRepository.recordFailedAttempt(eqTo(registrationId))) + .thenReturn(Future.successful(3)) await(service.incrementBarsAttempts(registrationId)) mustBe 3 } } - // ---- isBarsLocked ---- - "isBarsLocked" should { "return true when the user is locked in the repository" in new Setup { - when(mockUserLockRepository.isUserLocked(eqTo(registrationId))) + when(mockBarsLockRepository.isLocked(eqTo(registrationId))) .thenReturn(Future.successful(true)) await(service.isBarsLocked(registrationId)) mustBe true } "return false when the user is not locked in the repository" in new Setup { - when(mockUserLockRepository.isUserLocked(eqTo(registrationId))) + when(mockBarsLockRepository.isLocked(eqTo(registrationId))) .thenReturn(Future.successful(false)) await(service.isBarsLocked(registrationId)) mustBe false From 430fb97cf1a6ddc7774f492b1f5f2718475913b8 Mon Sep 17 00:00:00 2001 From: HG-Accenture <183600681+HG-Accenture@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:41:26 +0000 Subject: [PATCH 23/27] Lock service --- .../HasCompanyBankAccountViewSpec.scala | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala b/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala index a2768174f..02eea18f8 100644 --- a/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala +++ b/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala @@ -27,14 +27,15 @@ class HasCompanyBankAccountViewSpec extends VatRegViewSpec { implicit val doc: Document = Jsoup.parse(view(HasCompanyBankAccountForm.form).body) lazy val view: HasCompanyBankAccountView = app.injector.instanceOf[HasCompanyBankAccountView] - val heading = "Are you able to provide bank or building society account details for the business?" + val heading = "Can you provide bank or building society details for VAT repayments to the business?" val title = s"$heading - Register for VAT - GOV.UK" - val para = "If we owe the business money, we can repay this directly into your bank by BACS. This is faster and more secure than HMRC cheques." - val para2 = "The account does not have to be a dedicated business account but it must be:" - val bullet1 = "separate from a personal account" - val bullet2 = "in the name of the registered person or company" - val bullet3 = "in the UK" - val bullet4 = "able to receive BACS payments" + val para = "If HMRC owes the business money, it will repay this directly to your account." + val para2 = "BACS is normally used for VAT repayments because this is a faster and more secure payment method than a cheque." + val para3 = "The account you select to receive VAT repayments must be:" + val bullet1 = "Used only for this business" + val bullet2 = "In the name of the individual or company registering for VAT" + val bullet3 = "Based in the UK" + val bullet4 = "Able to receive BACS payments" val yes = "Yes" val no = "No" val continue = "Save and continue" @@ -55,6 +56,7 @@ class HasCompanyBankAccountViewSpec extends VatRegViewSpec { "have correct text" in new ViewSetup { doc.para(1) mustBe Some(para) doc.para(2) mustBe Some(para2) + doc.para(3) mustBe Some(para3) } "have correct bullets" in new ViewSetup { From 8dfa816e2980b87b595b637996021cc0ee6cd68f Mon Sep 17 00:00:00 2001 From: Hugo Greenwood <183600681+HG-Accenture@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:47:48 +0000 Subject: [PATCH 24/27] Role back headings and paragraphs in bank details view spec --- .../HasCompanyBankAccountViewSpec.scala | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala b/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala index 02eea18f8..a2768174f 100644 --- a/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala +++ b/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala @@ -27,15 +27,14 @@ class HasCompanyBankAccountViewSpec extends VatRegViewSpec { implicit val doc: Document = Jsoup.parse(view(HasCompanyBankAccountForm.form).body) lazy val view: HasCompanyBankAccountView = app.injector.instanceOf[HasCompanyBankAccountView] - val heading = "Can you provide bank or building society details for VAT repayments to the business?" + val heading = "Are you able to provide bank or building society account details for the business?" val title = s"$heading - Register for VAT - GOV.UK" - val para = "If HMRC owes the business money, it will repay this directly to your account." - val para2 = "BACS is normally used for VAT repayments because this is a faster and more secure payment method than a cheque." - val para3 = "The account you select to receive VAT repayments must be:" - val bullet1 = "Used only for this business" - val bullet2 = "In the name of the individual or company registering for VAT" - val bullet3 = "Based in the UK" - val bullet4 = "Able to receive BACS payments" + val para = "If we owe the business money, we can repay this directly into your bank by BACS. This is faster and more secure than HMRC cheques." + val para2 = "The account does not have to be a dedicated business account but it must be:" + val bullet1 = "separate from a personal account" + val bullet2 = "in the name of the registered person or company" + val bullet3 = "in the UK" + val bullet4 = "able to receive BACS payments" val yes = "Yes" val no = "No" val continue = "Save and continue" @@ -56,7 +55,6 @@ class HasCompanyBankAccountViewSpec extends VatRegViewSpec { "have correct text" in new ViewSetup { doc.para(1) mustBe Some(para) doc.para(2) mustBe Some(para2) - doc.para(3) mustBe Some(para3) } "have correct bullets" in new ViewSetup { From 9fcaa013193090b7e685674b720b1b8127f972e0 Mon Sep 17 00:00:00 2001 From: HG-Accenture <183600681+HG-Accenture@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:41:26 +0000 Subject: [PATCH 25/27] Lock service --- .../HasCompanyBankAccountViewSpec.scala | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala b/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala index a2768174f..02eea18f8 100644 --- a/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala +++ b/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala @@ -27,14 +27,15 @@ class HasCompanyBankAccountViewSpec extends VatRegViewSpec { implicit val doc: Document = Jsoup.parse(view(HasCompanyBankAccountForm.form).body) lazy val view: HasCompanyBankAccountView = app.injector.instanceOf[HasCompanyBankAccountView] - val heading = "Are you able to provide bank or building society account details for the business?" + val heading = "Can you provide bank or building society details for VAT repayments to the business?" val title = s"$heading - Register for VAT - GOV.UK" - val para = "If we owe the business money, we can repay this directly into your bank by BACS. This is faster and more secure than HMRC cheques." - val para2 = "The account does not have to be a dedicated business account but it must be:" - val bullet1 = "separate from a personal account" - val bullet2 = "in the name of the registered person or company" - val bullet3 = "in the UK" - val bullet4 = "able to receive BACS payments" + val para = "If HMRC owes the business money, it will repay this directly to your account." + val para2 = "BACS is normally used for VAT repayments because this is a faster and more secure payment method than a cheque." + val para3 = "The account you select to receive VAT repayments must be:" + val bullet1 = "Used only for this business" + val bullet2 = "In the name of the individual or company registering for VAT" + val bullet3 = "Based in the UK" + val bullet4 = "Able to receive BACS payments" val yes = "Yes" val no = "No" val continue = "Save and continue" @@ -55,6 +56,7 @@ class HasCompanyBankAccountViewSpec extends VatRegViewSpec { "have correct text" in new ViewSetup { doc.para(1) mustBe Some(para) doc.para(2) mustBe Some(para2) + doc.para(3) mustBe Some(para3) } "have correct bullets" in new ViewSetup { From 787a358e77615a85d6f21d1bf43ddacff809c4e3 Mon Sep 17 00:00:00 2001 From: Hugo Greenwood <183600681+HG-Accenture@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:47:48 +0000 Subject: [PATCH 26/27] Role back headings and paragraphs in bank details view spec --- .../HasCompanyBankAccountViewSpec.scala | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala b/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala index 02eea18f8..a2768174f 100644 --- a/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala +++ b/test/views/bankdetails/HasCompanyBankAccountViewSpec.scala @@ -27,15 +27,14 @@ class HasCompanyBankAccountViewSpec extends VatRegViewSpec { implicit val doc: Document = Jsoup.parse(view(HasCompanyBankAccountForm.form).body) lazy val view: HasCompanyBankAccountView = app.injector.instanceOf[HasCompanyBankAccountView] - val heading = "Can you provide bank or building society details for VAT repayments to the business?" + val heading = "Are you able to provide bank or building society account details for the business?" val title = s"$heading - Register for VAT - GOV.UK" - val para = "If HMRC owes the business money, it will repay this directly to your account." - val para2 = "BACS is normally used for VAT repayments because this is a faster and more secure payment method than a cheque." - val para3 = "The account you select to receive VAT repayments must be:" - val bullet1 = "Used only for this business" - val bullet2 = "In the name of the individual or company registering for VAT" - val bullet3 = "Based in the UK" - val bullet4 = "Able to receive BACS payments" + val para = "If we owe the business money, we can repay this directly into your bank by BACS. This is faster and more secure than HMRC cheques." + val para2 = "The account does not have to be a dedicated business account but it must be:" + val bullet1 = "separate from a personal account" + val bullet2 = "in the name of the registered person or company" + val bullet3 = "in the UK" + val bullet4 = "able to receive BACS payments" val yes = "Yes" val no = "No" val continue = "Save and continue" @@ -56,7 +55,6 @@ class HasCompanyBankAccountViewSpec extends VatRegViewSpec { "have correct text" in new ViewSetup { doc.para(1) mustBe Some(para) doc.para(2) mustBe Some(para2) - doc.para(3) mustBe Some(para3) } "have correct bullets" in new ViewSetup { From 59f4e924e71403bcf4fafd30986dd81bc55dbad6 Mon Sep 17 00:00:00 2001 From: mi-aspectratio <254715997+mi-aspectratio@users.noreply.github.com> Date: Tue, 17 Mar 2026 11:40:50 +0000 Subject: [PATCH 27/27] feat: lockout pages --- app/config/FrontendAppConfig.scala | 7 -- .../AccountDetailsNotVerified.scala | 54 --------- .../AccountDetailsNotVerifiedController.scala | 47 ++++++++ .../CheckBankDetailsController.scala | 32 +++--- .../UkBankAccountDetailsController.scala | 14 +-- ...ala => BankDetailsLockoutController.scala} | 25 ++-- app/repositories/BarsLockRepository.scala | 88 ++++++++++++++ app/repositories/UserLockRepository.scala | 108 ------------------ app/services/LockService.scala | 39 ++----- .../AccountDetailsNotVerifiedView.scala.html | 39 +++---- ...html => BankDetailsLockoutPage.scala.html} | 20 ++-- conf/app.routes | 10 +- conf/messages | 29 +++-- ...untDetailsNotVerifiedControllerISpec.scala | 29 ++--- .../CheckBankDetailsControllerISpec.scala | 4 +- it/test/support/AppAndStubs.scala | 7 +- ...=> BankDetailsLockoutControllerSpec.scala} | 11 +- test/services/LockServiceSpec.scala | 35 +++--- 18 files changed, 269 insertions(+), 329 deletions(-) delete mode 100644 app/controllers/bankdetails/AccountDetailsNotVerified.scala create mode 100644 app/controllers/bankdetails/AccountDetailsNotVerifiedController.scala rename app/controllers/errors/{ThirdAttemptLockoutController.scala => BankDetailsLockoutController.scala} (57%) create mode 100644 app/repositories/BarsLockRepository.scala delete mode 100644 app/repositories/UserLockRepository.scala rename app/views/errors/{ThirdAttemptLockoutPage.scala.html => BankDetailsLockoutPage.scala.html} (65%) rename test/controllers/bankdetails/AccountDetailsNotVerifiedSpec.scala => it/test/controllers/bankdetails/AccountDetailsNotVerifiedControllerISpec.scala (71%) rename test/controllers/errors/{ThirdAttemptLockoutControllerSpec.scala => BankDetailsLockoutControllerSpec.scala} (79%) diff --git a/app/config/FrontendAppConfig.scala b/app/config/FrontendAppConfig.scala index 1d1c93196..82dc01cb6 100644 --- a/app/config/FrontendAppConfig.scala +++ b/app/config/FrontendAppConfig.scala @@ -45,13 +45,6 @@ class FrontendAppConfig @Inject()(val servicesConfig: ServicesConfig, runModeCon lazy val eligibilityQuestionUrl: String = loadConfig("microservice.services.vat-registration-eligibility-frontend.question") implicit val appConfig: FrontendAppConfig = this - lazy val ttlLockSeconds:Int = 86400 - lazy val knownFactsLockAttemptLimit:Int = 3 - lazy val isKnownFactsCheckEnabled:Boolean = true - - - - private lazy val thresholdString: String = runModeConfiguration.get[ConfigList]("vat-threshold").render(ConfigRenderOptions.concise()) lazy val thresholds: Seq[VatThreshold] = Json.parse(thresholdString).as[List[VatThreshold]] diff --git a/app/controllers/bankdetails/AccountDetailsNotVerified.scala b/app/controllers/bankdetails/AccountDetailsNotVerified.scala deleted file mode 100644 index 6a6884afb..000000000 --- a/app/controllers/bankdetails/AccountDetailsNotVerified.scala +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2026 HM Revenue & Customs - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package controllers.bankdetails - -import config.{AuthClientConnector, BaseControllerComponents, FrontendAppConfig} -import controllers.BaseController -import play.api.data.Form -import play.api.data.Forms.{boolean, single} -import play.api.mvc.{Action, AnyContent} -import services.{LockService, SessionService} -import views.html.bankdetails.AccountDetailsNotVerifiedView - -import javax.inject.Inject -import scala.concurrent.ExecutionContext - -class AccountDetailsNotVerified @Inject()(val authConnector: AuthClientConnector, - val sessionService: SessionService, - lockService: LockService, - view: AccountDetailsNotVerifiedView) - (implicit appConfig: FrontendAppConfig, - val executionContext: ExecutionContext, - baseControllerComponents: BaseControllerComponents) extends BaseController { - - private val AttemptForm: Form[Boolean] = Form(single("value" -> boolean)) - - def show: Action[AnyContent] = isAuthenticatedWithProfile { - implicit request => implicit profile => - lockService.getBarsAttemptsUsed(profile.registrationId).map { attemptsUsed => - if (attemptsUsed >= appConfig.knownFactsLockAttemptLimit) { - Redirect(controllers.errors.routes.ThirdAttemptLockoutController.show) - } else { - val formWithAttempts = AttemptForm.bind(Map( - "value" -> "true", - "attempts" -> attemptsUsed.toString - )) - Ok(view(formWithAttempts)) - } - } - } -} diff --git a/app/controllers/bankdetails/AccountDetailsNotVerifiedController.scala b/app/controllers/bankdetails/AccountDetailsNotVerifiedController.scala new file mode 100644 index 000000000..6fea4a8cd --- /dev/null +++ b/app/controllers/bankdetails/AccountDetailsNotVerifiedController.scala @@ -0,0 +1,47 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers.bankdetails + +import config.{AuthClientConnector, BaseControllerComponents, FrontendAppConfig} +import controllers.BaseController +import play.api.mvc.{Action, AnyContent} +import services.{LockService, SessionService} +import views.html.bankdetails.AccountDetailsNotVerifiedView + +import javax.inject.Inject +import scala.concurrent.{ExecutionContext, Future} + +class AccountDetailsNotVerifiedController @Inject()( + val authConnector: AuthClientConnector, + val sessionService: SessionService, + lockService: LockService, + view: AccountDetailsNotVerifiedView + )(implicit appConfig: FrontendAppConfig, + val executionContext: ExecutionContext, + baseControllerComponents: BaseControllerComponents) extends BaseController { + + def show: Action[AnyContent] = isAuthenticatedWithProfile { + implicit request => implicit profile => + lockService.isBarsLocked(profile.registrationId).flatMap { + case true => Future.successful(Redirect(controllers.errors.routes.BankDetailsLockoutController.show)) + case false => + lockService.getBarsAttemptsUsed(profile.registrationId).map { attemptsUsed => + Ok(view(attemptsUsed)) + } + } + } +} \ No newline at end of file diff --git a/app/controllers/bankdetails/CheckBankDetailsController.scala b/app/controllers/bankdetails/CheckBankDetailsController.scala index 3a2f118de..9056ae01f 100644 --- a/app/controllers/bankdetails/CheckBankDetailsController.scala +++ b/app/controllers/bankdetails/CheckBankDetailsController.scala @@ -25,7 +25,7 @@ import models.bars.BankAccountDetailsSessionFormat import play.api.Configuration import play.api.libs.json.Format import play.api.mvc.{Action, AnyContent} -import services.{BankAccountDetailsService,LockService, SessionService} +import services.{BankAccountDetailsService, LockService, SessionService} import uk.gov.hmrc.crypto.SymmetricCryptoFactory import views.html.bankdetails.CheckBankDetailsView @@ -50,11 +50,16 @@ class CheckBankDetailsController @Inject() ( private val sessionKey = "bankAccountDetails" - def show: Action[AnyContent] = isAuthenticated { implicit request => + def show: Action[AnyContent] = isAuthenticatedWithProfile { implicit request => implicit profile => if (isEnabled(UseNewBarsVerify)) { - sessionService.fetchAndGet[BankAccountDetails](sessionKey).map { - case Some(details) => Ok(view(details)) - case None => Redirect(routes.HasBankAccountController.show) + lockService.isBarsLocked(profile.registrationId).flatMap { + case true => Future.successful(Redirect(controllers.errors.routes.BankDetailsLockoutController.show)) + case false => + sessionService.fetchAndGet[BankAccountDetails](sessionKey).map { + case Some(details) => Ok(view(details)) + case None => Redirect(routes.HasBankAccountController.show) + } + } } else { Future.successful(Redirect(routes.HasBankAccountController.show)) @@ -68,18 +73,17 @@ class CheckBankDetailsController @Inject() ( bankAccount <- bankAccountDetailsService.getBankAccount result <- (details, bankAccount.flatMap(_.bankAccountType)) match { case (Some(accountDetails), Some(accountType)) => - bankAccountDetailsService.saveEnteredBankAccountDetails(accountDetails, Some(accountType)).map { - case true => Redirect(controllers.routes.TaskListController.show.url) + bankAccountDetailsService.saveEnteredBankAccountDetails(accountDetails, Some(accountType)).flatMap { + case true => Future.successful(Redirect(controllers.routes.TaskListController.show.url)) case false => - lockService.incrementBarsAttempts(profile.registrationId).map { attempts => - if (attempts >= appConfig.knownFactsLockAttemptLimit) { - Redirect(controllers.errors.routes.ThirdAttemptLockoutController.show) - } else { - Redirect(controllers.bankdetails.routes.AccountDetailsNotVerified.show) + lockService.incrementBarsAttempts(profile.registrationId).flatMap { _ => + lockService.isBarsLocked(profile.registrationId).map { + case true => + Redirect(controllers.errors.routes.BankDetailsLockoutController.show) + case false => + Redirect(controllers.bankdetails.routes.AccountDetailsNotVerifiedController.show) } } - Redirect(routes.UkBankAccountDetailsController.show) - } case _ => Future.successful(Redirect(routes.HasBankAccountController.show)) } diff --git a/app/controllers/bankdetails/UkBankAccountDetailsController.scala b/app/controllers/bankdetails/UkBankAccountDetailsController.scala index d598ad66a..e6b1aee45 100644 --- a/app/controllers/bankdetails/UkBankAccountDetailsController.scala +++ b/app/controllers/bankdetails/UkBankAccountDetailsController.scala @@ -27,7 +27,7 @@ import models.bars.BankAccountDetailsSessionFormat import play.api.libs.json.Format import play.api.mvc.{Action, AnyContent} import play.api.Configuration -import services.{BankAccountDetailsService,LockService, SessionService} +import services.{BankAccountDetailsService, LockService, SessionService} import uk.gov.hmrc.crypto.SymmetricCryptoFactory import views.html.bankdetails.{EnterBankAccountDetails, EnterCompanyBankAccountDetails} @@ -55,26 +55,22 @@ class UkBankAccountDetailsController @Inject() ( def show: Action[AnyContent] = isAuthenticatedWithProfile { implicit request => implicit profile => if (isEnabled(UseNewBarsVerify)) { - lockService.getBarsAttemptsUsed(profile.registrationId).map(_ >= appConfig.knownFactsLockAttemptLimit).flatMap { - case true => Future.successful(Redirect(controllers.errors.routes.ThirdAttemptLockoutController.show)) + lockService.isBarsLocked(profile.registrationId).flatMap { + case true => Future.successful(Redirect(controllers.errors.routes.BankDetailsLockoutController.show)) case false => val newBarsForm = EnterBankAccountDetailsForm.form sessionService.fetchAndGet[BankAccountDetails](sessionKey).map { case Some(details) => Ok(newBarsView(newBarsForm.fill(details))) - case None => Ok(newBarsView(newBarsForm)) + case None => Ok(newBarsView(newBarsForm)) } } - } - - - else { + } else { for { bankDetails <- bankAccountDetailsService.getBankAccount filledForm = bankDetails.flatMap(_.details).fold(enterBankAccountDetailsForm)(enterBankAccountDetailsForm.fill) } yield Ok(oldView(filledForm)) } } - def submit: Action[AnyContent] = isAuthenticatedWithProfile { implicit request => implicit profile => if (isEnabled(UseNewBarsVerify)) { val newBarsForm = EnterBankAccountDetailsForm.form diff --git a/app/controllers/errors/ThirdAttemptLockoutController.scala b/app/controllers/errors/BankDetailsLockoutController.scala similarity index 57% rename from app/controllers/errors/ThirdAttemptLockoutController.scala rename to app/controllers/errors/BankDetailsLockoutController.scala index 2255ae189..ff7489958 100644 --- a/app/controllers/errors/ThirdAttemptLockoutController.scala +++ b/app/controllers/errors/BankDetailsLockoutController.scala @@ -16,27 +16,22 @@ package controllers.errors - +import config.FrontendAppConfig import play.api.mvc.{Action, AnyContent, MessagesControllerComponents} import uk.gov.hmrc.auth.core.{AuthConnector, AuthorisedFunctions} -import config.FrontendAppConfig import uk.gov.hmrc.play.bootstrap.frontend.controller.FrontendController -import services.SessionService -import views.html.errors.ThirdAttemptLockoutPage +import views.html.errors.BankDetailsLockoutPage import javax.inject.{Inject, Singleton} -import scala.concurrent.{ExecutionContext, Future} +import scala.concurrent.Future @Singleton -class ThirdAttemptLockoutController @Inject()(mcc: MessagesControllerComponents, - view: ThirdAttemptLockoutPage, - val authConnector: AuthConnector - )(implicit appConfig: FrontendAppConfig, ec: ExecutionContext) extends FrontendController(mcc) with AuthorisedFunctions { +class BankDetailsLockoutController @Inject()(mcc: MessagesControllerComponents, view: BankDetailsLockoutPage, val authConnector: AuthConnector)( + implicit appConfig: FrontendAppConfig) + extends FrontendController(mcc) + with AuthorisedFunctions { - def show(): Action[AnyContent] = Action.async { - implicit request => - authorised() { - Future.successful(Ok(view())) - } + def show: Action[AnyContent] = Action.async { implicit request => + Future.successful(Ok(view())) } -} \ No newline at end of file +} diff --git a/app/repositories/BarsLockRepository.scala b/app/repositories/BarsLockRepository.scala new file mode 100644 index 000000000..d4936f8eb --- /dev/null +++ b/app/repositories/BarsLockRepository.scala @@ -0,0 +1,88 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package repositories + +import models.Lock +import models.Lock._ +import org.mongodb.scala.model._ +import org.mongodb.scala.model.Indexes.ascending +import play.api.libs.json._ +import uk.gov.hmrc.mongo.MongoComponent +import uk.gov.hmrc.mongo.play.json.PlayMongoRepository + +import java.time.Instant +import java.util.concurrent.TimeUnit +import javax.inject.{Inject, Singleton} +import scala.concurrent.{ExecutionContext, Future} + +@Singleton +class BarsLockRepository @Inject() ( + mongoComponent: MongoComponent +)(implicit ec: ExecutionContext) + extends PlayMongoRepository[Lock]( + collectionName = "bars-lock", + mongoComponent = mongoComponent, + domainFormat = implicitly[Format[Lock]], + indexes = Seq( + IndexModel( + keys = ascending("lastAttemptedAt"), + indexOptions = IndexOptions() + .name("BarsLockExpires") + .expireAfter(24, TimeUnit.HOURS) + ), + IndexModel( + keys = ascending("identifier"), + indexOptions = IndexOptions() + .name("BarsIdentifierIdx") + .sparse(true) + .unique(true) + ) + ), + replaceIndexes = true + ) { + + private val attemptLimit = 3 + + def getAttemptsUsed(registrationId: String): Future[Int] = + collection + .find(Filters.eq("identifier", registrationId)) + .headOption() + .map(_.map(_.failedAttempts).getOrElse(0)) + + def isLocked(registrationId: String): Future[Boolean] = + getAttemptsUsed(registrationId).map(_ >= attemptLimit) + + def recordFailedAttempt(registrationId: String): Future[Int] = { + val update = Updates.combine( + Updates.inc("failedAttempts", 1), + Updates.set("lastAttemptedAt", Instant.now()), + Updates.setOnInsert("identifier", registrationId) + ) + val options = FindOneAndUpdateOptions() + .upsert(true) + .returnDocument(ReturnDocument.AFTER) + + collection + .findOneAndUpdate( + Filters.eq("identifier", registrationId), + update, + options + ) + .toFutureOption() + .map(_.map(_.failedAttempts).getOrElse(1)) + } +} diff --git a/app/repositories/UserLockRepository.scala b/app/repositories/UserLockRepository.scala deleted file mode 100644 index f5942abc2..000000000 --- a/app/repositories/UserLockRepository.scala +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright 2026 HM Revenue & Customs - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package repositories - -import org.mongodb.scala.model.Indexes.ascending -import org.mongodb.scala.model._ -import play.api.libs.json._ -import config.FrontendAppConfig -import models.Lock -import models.Lock._ -import uk.gov.hmrc.mongo.MongoComponent -import uk.gov.hmrc.mongo.play.json.PlayMongoRepository - -import java.time.Instant -import java.util.concurrent.TimeUnit -import javax.inject.{Inject, Singleton} -import scala.concurrent.{ExecutionContext, Future} - -@Singleton -class UserLockRepository @Inject()( - mongoComponent: MongoComponent, - appConfig: FrontendAppConfig - )(implicit ec: ExecutionContext) extends PlayMongoRepository[Lock]( - collectionName = "user-lock", - mongoComponent = mongoComponent, - domainFormat = implicitly[Format[Lock]], - indexes = Seq( - IndexModel( - keys = ascending("lastAttemptedAt"), - indexOptions = IndexOptions() - .name("CVEInvalidDataLockExpires") - .expireAfter(appConfig.ttlLockSeconds, TimeUnit.SECONDS) - ), - IndexModel( - keys = ascending("identifier"), - indexOptions = IndexOptions() - .name("IdentifierIdx") - .sparse(true) - .unique(true) - ) - ), - replaceIndexes = true -) { - - def getFailedAttempts(identifier: String): Future[Int] = - collection - .find(Filters.eq("identifier", identifier)) - .headOption() - .map(_.map(_.failedAttempts).getOrElse(0)) - - def isUserLocked(userId: String): Future[Boolean] = { - collection - .find(Filters.in("identifier", userId)) - .toFuture() - .map { _.exists { _.failedAttempts >= appConfig.knownFactsLockAttemptLimit }} - } - - def updateAttempts(userId: String): Future[Map[String, Int]] = { - def updateAttemptsForLockWith(identifier: String): Future[Lock] = { - collection - .find(Filters.eq("identifier", identifier)) - .headOption() - .flatMap { - case Some(existingLock) => - val newLock = existingLock.copy( - failedAttempts = existingLock.failedAttempts + 1, - lastAttemptedAt = Instant.now() - ) - collection.replaceOne( - Filters.and( - Filters.eq("identifier", identifier) - ), - newLock - ) - .toFuture() - .map(_ => newLock) - case _ => - val newLock = Lock(identifier, 1, Instant.now) - collection.insertOne(newLock) - .toFuture() - .map(_ => newLock) - } - } - val updateUserLock = updateAttemptsForLockWith(userId) - - for { - userLock <- updateUserLock - } yield { - Map( - "user" -> userLock.failedAttempts - ) - } - } -} \ No newline at end of file diff --git a/app/services/LockService.scala b/app/services/LockService.scala index 49f67ad24..39aa95f76 100644 --- a/app/services/LockService.scala +++ b/app/services/LockService.scala @@ -16,47 +16,22 @@ package services - -import play.api.mvc.Result -import play.api.mvc.Results.Redirect -import config.FrontendAppConfig -import controllers.errors -import repositories.UserLockRepository +import repositories.BarsLockRepository import utils.LoggingUtil import javax.inject.{Inject, Singleton} -import scala.concurrent.{ExecutionContext, Future} +import scala.concurrent.Future @Singleton -class LockService @Inject()(userLockRepository: UserLockRepository, - config: FrontendAppConfig)(implicit ec: ExecutionContext) extends LoggingUtil{ - - - - def updateAttempts(userId: String): Future[Map[String, Int]] = { - if (config.isKnownFactsCheckEnabled) { - userLockRepository.updateAttempts(userId) - } else { - Future.successful(Map.empty) - } - } - - def isJourneyLocked(userId: String): Future[Boolean] = { - if (config.isKnownFactsCheckEnabled) { - userLockRepository.isUserLocked(userId) - } else { - Future.successful(false) - } - } - - // ---- BARs bank account lock methods ---- +class LockService @Inject() (barsLockRepository: BarsLockRepository) extends LoggingUtil { def getBarsAttemptsUsed(registrationId: String): Future[Int] = - userLockRepository.getFailedAttempts(registrationId) + barsLockRepository.getAttemptsUsed(registrationId) def incrementBarsAttempts(registrationId: String): Future[Int] = - userLockRepository.updateAttempts(registrationId).map(_.getOrElse("user", 0)) + barsLockRepository.recordFailedAttempt(registrationId) def isBarsLocked(registrationId: String): Future[Boolean] = - userLockRepository.isUserLocked(registrationId) + barsLockRepository.isLocked(registrationId) + } diff --git a/app/views/bankdetails/AccountDetailsNotVerifiedView.scala.html b/app/views/bankdetails/AccountDetailsNotVerifiedView.scala.html index 82bebb867..8a57716a6 100644 --- a/app/views/bankdetails/AccountDetailsNotVerifiedView.scala.html +++ b/app/views/bankdetails/AccountDetailsNotVerifiedView.scala.html @@ -15,43 +15,42 @@ *@ @import config.FrontendAppConfig -@import play.api.data.Form @import play.api.i18n.Messages @import play.api.mvc.Request -@import uk.gov.hmrc.govukfrontend.views.html.components._ @this( - layout: layouts.layout, - h1: components.h1, - p: components.p, - formWithCSRF: FormWithCSRF, - errorSummary: components.errorSummary, + layout: layouts.layout, + h1: components.h1, + p: components.p, + govukBackLink: GovukBackLink ) -@(form: Form[Boolean])(implicit request: Request[_], messages: Messages, appConfig: FrontendAppConfig) +@(attemptsUsed: Int)(implicit request: Request[_], messages: Messages, appConfig: FrontendAppConfig) -@layout(pageTitle = Some(title(form, messages("pages.accountDetailsCouldNotBeVerified.heading")))){ - - @errorSummary(errors = form.errors) - +@layout( + pageTitle = Some(messages("pages.accountDetailsCouldNotBeVerified.heading")), + backLink = false +) { @h1("pages.accountDetailsCouldNotBeVerified.heading") @p { - @messages("pages.accountDetailsCouldNotBeVerified.para1", 3 - form.data.get("attempts").map(_.toInt).getOrElse(0)) + @messages("pages.accountDetailsCouldNotBeVerified.text.pre") + @if(attemptsUsed == 2) { + @messages("pages.accountDetailsCouldNotBeVerified.one.remaining") + } else { + @messages("pages.accountDetailsCouldNotBeVerified.two.remaining") + } + @messages("pages.accountDetailsCouldNotBeVerified.text.post") } @p { - @Html(messages("pages.accountDetailsCouldNotBeVerified.para2", appConfig.vatTaskList)) + @Html(messages("pages.accountDetailsCouldNotBeVerified.para2", controllers.bankdetails.routes.UkBankAccountDetailsController.show.url)) } -@* - * @p { - * @messages("pages.accountDetailsCouldNotBeVerified.para2") - * } -*@ @p { - @messages("pages.accountDetailsCouldNotBeVerified.para3.bold") @messages("pages.accountDetailsCouldNotBeVerified.para3") + @messages("pages.accountDetailsCouldNotBeVerified.para3.bold") + @messages("pages.accountDetailsCouldNotBeVerified.para3") } } \ No newline at end of file diff --git a/app/views/errors/ThirdAttemptLockoutPage.scala.html b/app/views/errors/BankDetailsLockoutPage.scala.html similarity index 65% rename from app/views/errors/ThirdAttemptLockoutPage.scala.html rename to app/views/errors/BankDetailsLockoutPage.scala.html index d348775a8..41ffeba60 100644 --- a/app/views/errors/ThirdAttemptLockoutPage.scala.html +++ b/app/views/errors/BankDetailsLockoutPage.scala.html @@ -19,31 +19,31 @@ @import play.api.mvc.Request @import views.html.components._ @import views.html.layouts.layout -@import uk.gov.hmrc.govukfrontend.views.html.components._ @this(layout: layout, - h1: h1, - p: p, - govUkHeader: GovukHeader, - govukButton: GovukButton + h1: h1, + p: p, + link: link + ) @()(implicit request: Request[_], messages: Messages, appConfig: FrontendAppConfig) -@layout(Some(titleNoForm(messages("ThirdAttemptLockoutPage.heading"))), backLink = false) { +@layout(Some(titleNoForm(messages("pages.bankDetailsLockoutPage.heading"))), backLink = false) { - @h1(messages("ThirdAttemptLockoutPage.heading")) + @h1(messages("pages.bankDetailsLockoutPage.heading")) @p { - @messages("pages.ThirdAttemptLockoutPage.para1") + @messages("pages.bankDetailsLockoutPage.para1") } @p { - @messages("pages.ThirdAttemptLockoutPage.para2") + @messages("pages.bankDetailsLockoutPage.para2") } @p { - @Html(messages("pages.ThirdAttemptLockoutPage.para3", appConfig.vatTaskList)) + @link(controllers.routes.TaskListController.show.url, messages("pages.bankDetailsLockoutPage.para3.link"))@messages("pages.bankDetailsLockoutPage.para3") } + } \ No newline at end of file diff --git a/conf/app.routes b/conf/app.routes index 9bc0cb149..a8bbe3f63 100644 --- a/conf/app.routes +++ b/conf/app.routes @@ -138,6 +138,10 @@ POST /account-details controller GET /check-bank-details controllers.bankdetails.CheckBankDetailsController.show POST /check-bank-details controllers.bankdetails.CheckBankDetailsController.submit +# FAILED VERIFICATION SCREENS +GET /failed-verification controllers.bankdetails.AccountDetailsNotVerifiedController.show +GET /verification-lockout controllers.errors.BankDetailsLockoutController.show + ## VAT CORRESPONDENCE GET /vat-correspondence-language controllers.business.VatCorrespondenceController.show POST /vat-correspondence-language controllers.business.VatCorrespondenceController.submit @@ -634,8 +638,4 @@ POST /partner/:index/partner-telephone controller ## Partner Email Address Page GET /partner/:index/email-address controllers.partners.PartnerCaptureEmailAddressController.show(index: Int) -POST /partner/:index/email-address controllers.partners.PartnerCaptureEmailAddressController.submit(index: Int) - -# Lockout screens -GET /failed-third-attempt controllers.errors.ThirdAttemptLockoutController.show -GET /failed-attempt controllers.bankdetails.AccountDetailsNotVerified.show \ No newline at end of file +POST /partner/:index/email-address controllers.partners.PartnerCaptureEmailAddressController.submit(index: Int) \ No newline at end of file diff --git a/conf/messages b/conf/messages index c62bcfe0e..6fc7f658e 100644 --- a/conf/messages +++ b/conf/messages @@ -1030,6 +1030,23 @@ pages.checkBankDetails.sortCode = Sort code pages.checkBankDetails.rollNumber = Building society roll number pages.checkBankDetails.p1 = By confirming these account details, you agree the information you have provided is complete and correct. +# Account Details could not be verified +pages.accountDetailsCouldNotBeVerified.heading = We could not verify the bank details you provided +pages.accountDetailsCouldNotBeVerified.text.pre = You have +pages.accountDetailsCouldNotBeVerified.one.remaining = one more attempt +pages.accountDetailsCouldNotBeVerified.two.remaining = two more attempts +pages.accountDetailsCouldNotBeVerified.text.post = to provide your account details. +pages.accountDetailsCouldNotBeVerified.para2 = Enter your bank account or building society details again, making sure the name matches exactly as it appears on your account. +pages.accountDetailsCouldNotBeVerified.para3.bold = After 3 consecutive unsuccessful attempts, +pages.accountDetailsCouldNotBeVerified.para3 = you will need to complete your VAT registration before sending us your details. + +# Locked Out Page +pages.bankDetailsLockoutPage.heading = Account details could not be verified +pages.bankDetailsLockoutPage.para1 = We have been unable to verify the account details you supplied. +pages.bankDetailsLockoutPage.para2 = For your security, we have paused this part of the service. +pages.bankDetailsLockoutPage.para3.link = You can return to the VAT registration task list now +pages.bankDetailsLockoutPage.para3 =, and provide your account details later once your registration is confirmed. + # Main Business Activity Page pages.mainBusinessActivity.heading = Which activity is the business’s main source of income? validation.mainBusinessActivity.missing = Select the business’s main source of income @@ -2049,15 +2066,3 @@ partnerEmail.link = HMRC Privacy Notice partnerEmail.error.incorrect_format = Enter the email address in the correct format, like name@example.com partnerEmail.error.nothing_entered = Enter the email address partnerEmail.error.incorrect_length = The email address must be 132 characters or fewer - -#Account Details could not be verified -pages.accountDetailsCouldNotBeVerified.heading = We could not verify the bank details you provided -pages.accountDetailsCouldNotBeVerified.para1 = You have {0} more attempts to provide your account details. -pages.accountDetailsCouldNotBeVerified.para2 = Enter your bank account or building society details again, making sure the name matches exactly as it appears on your account. -pages.accountDetailsCouldNotBeVerified.para3.bold = After 3 consecutive unsuccessful attempts, -pages.accountDetailsCouldNotBeVerified.para3 = you will need to complete your VAT registration before sending us your details. - -ThirdAttemptLockoutPage.heading = Account details could not be verified -pages.ThirdAttemptLockoutPage.para1 = We have been unable to verify the account details you supplied. -pages.ThirdAttemptLockoutPage.para2 = For your security, we have paused this part of the service. -pages.ThirdAttemptLockoutPage.para3 = You can return to the VAT registration task list now, and provide your account details later once your registration is confirmed. \ No newline at end of file diff --git a/test/controllers/bankdetails/AccountDetailsNotVerifiedSpec.scala b/it/test/controllers/bankdetails/AccountDetailsNotVerifiedControllerISpec.scala similarity index 71% rename from test/controllers/bankdetails/AccountDetailsNotVerifiedSpec.scala rename to it/test/controllers/bankdetails/AccountDetailsNotVerifiedControllerISpec.scala index e58c58d10..a545636bd 100644 --- a/test/controllers/bankdetails/AccountDetailsNotVerifiedSpec.scala +++ b/it/test/controllers/bankdetails/AccountDetailsNotVerifiedControllerISpec.scala @@ -19,20 +19,19 @@ package controllers.bankdetails import fixtures.VatRegistrationFixture import org.mockito.ArgumentMatchers.any import org.mockito.Mockito._ -import play.api.test.FakeRequest import services.LockService import testHelpers.{ControllerSpec, FutureAssertions} import views.html.bankdetails.AccountDetailsNotVerifiedView import scala.concurrent.Future -class AccountDetailsNotVerifiedSpec extends ControllerSpec with VatRegistrationFixture with FutureAssertions { +class AccountDetailsNotVerifiedControllerISpec extends ControllerSpec with VatRegistrationFixture with FutureAssertions { val mockLockService: LockService = mock[LockService] val view: AccountDetailsNotVerifiedView = app.injector.instanceOf[AccountDetailsNotVerifiedView] trait Setup { - val testController = new AccountDetailsNotVerified( + val testController = new AccountDetailsNotVerifiedController( mockAuthClientConnector, mockSessionService, mockLockService, @@ -45,6 +44,8 @@ class AccountDetailsNotVerifiedSpec extends ControllerSpec with VatRegistrationF "show" should { "return 200 when attempts used is below the lockout limit" in new Setup { + when(mockLockService.isBarsLocked(any())) + .thenReturn(Future.successful(false)) when(mockLockService.getBarsAttemptsUsed(any())) .thenReturn(Future.successful(1)) @@ -54,7 +55,8 @@ class AccountDetailsNotVerifiedSpec extends ControllerSpec with VatRegistrationF } "return 200 when attempts used is one below the lockout limit" in new Setup { - // knownFactsLockAttemptLimit is 3 in appConfig, so 2 attempts should still show the page + when(mockLockService.isBarsLocked(any())) + .thenReturn(Future.successful(false)) when(mockLockService.getBarsAttemptsUsed(any())) .thenReturn(Future.successful(2)) @@ -63,25 +65,24 @@ class AccountDetailsNotVerifiedSpec extends ControllerSpec with VatRegistrationF } } - "redirect to ThirdAttemptLockout when attempts used is at or above the lockout limit" in new Setup { - when(mockLockService.getBarsAttemptsUsed(any())) - .thenReturn(Future.successful(3)) // >= appConfig.knownFactsLockAttemptLimit (3) + "redirect to BankDetailsLockoutController when attempts used is at or above the lockout limit" in new Setup { + when(mockLockService.isBarsLocked(any())) + .thenReturn(Future.successful(true)) callAuthorised(testController.show) { result => status(result) mustBe SEE_OTHER - redirectLocation(result) mustBe Some(controllers.errors.routes.ThirdAttemptLockoutController.show.url) + redirectLocation(result) mustBe Some(controllers.errors.routes.BankDetailsLockoutController.show.url) } } - "redirect to ThirdAttemptLockout when attempts used exceeds the lockout limit" in new Setup { - when(mockLockService.getBarsAttemptsUsed(any())) - .thenReturn(Future.successful(5)) + "redirect to BankDetailsLockoutController when attempts used exceeds the lockout limit" in new Setup { + when(mockLockService.isBarsLocked(any())) + .thenReturn(Future.successful(true)) callAuthorised(testController.show) { result => status(result) mustBe SEE_OTHER - redirectLocation(result) mustBe Some(controllers.errors.routes.ThirdAttemptLockoutController.show.url) + redirectLocation(result) mustBe Some(controllers.errors.routes.BankDetailsLockoutController.show.url) } } - } -} + }} diff --git a/it/test/controllers/bankdetails/CheckBankDetailsControllerISpec.scala b/it/test/controllers/bankdetails/CheckBankDetailsControllerISpec.scala index d6e82b50a..00d84bf33 100644 --- a/it/test/controllers/bankdetails/CheckBankDetailsControllerISpec.scala +++ b/it/test/controllers/bankdetails/CheckBankDetailsControllerISpec.scala @@ -140,7 +140,7 @@ class CheckBankDetailsControllerISpec extends ControllerISpec with ITRegistratio res.header(HeaderNames.LOCATION) mustBe Some(controllers.routes.TaskListController.show.url) } - "redirect back to UkBankAccountDetailsController when BARS verification fails" in new Setup { + "redirect to AccountDetailsNotVerifiedController when BARS verification fails" in new Setup { enable(UseNewBarsVerify) given().user .isAuthorised() @@ -162,7 +162,7 @@ class CheckBankDetailsControllerISpec extends ControllerISpec with ITRegistratio val res: WSResponse = await(buildClient(url).post(Map.empty[String, String])) res.status mustBe SEE_OTHER - res.header(HeaderNames.LOCATION) mustBe Some(routes.UkBankAccountDetailsController.show.url) + res.header(HeaderNames.LOCATION) mustBe Some(routes.AccountDetailsNotVerifiedController.show.url) } "redirect to HasBankAccountController when session is empty" in new Setup { diff --git a/it/test/support/AppAndStubs.scala b/it/test/support/AppAndStubs.scala index ad8f20b8d..991552dcf 100644 --- a/it/test/support/AppAndStubs.scala +++ b/it/test/support/AppAndStubs.scala @@ -25,11 +25,11 @@ import org.scalatestplus.play.guice.GuiceOneServerPerSuite import play.api.Application import play.api.http.HeaderNames import play.api.inject.guice.GuiceApplicationBuilder -import play.api.libs.json.{JsValue, Json, Writes} +import play.api.libs.json.{Json, JsValue, Writes} import play.api.libs.ws.{WSClient, WSRequest} import play.api.mvc.AnyContentAsFormUrlEncoded import play.api.test.FakeRequest -import repositories.SessionRepository +import repositories.{BarsLockRepository, SessionRepository} import uk.gov.hmrc.http.HeaderCarrier import uk.gov.hmrc.http.cache.client.CacheMap @@ -46,10 +46,13 @@ trait AppAndStubs extends StubUtils with GuiceOneServerPerSuite with Integration def customAwait[A](future: Future[A])(implicit timeout: Duration): A = Await.result(future, timeout) val repo: SessionRepository = app.injector.instanceOf[SessionRepository] + val barsLockRepository: BarsLockRepository = app.injector.instanceOf[BarsLockRepository] + val defaultTimeout: FiniteDuration = 2 seconds customAwait(repo.ensureIndexes())(defaultTimeout) customAwait(repo.collection.countDocuments().head())(defaultTimeout) + customAwait(barsLockRepository.collection.drop().head())(defaultTimeout) def insertCurrentProfileIntoDb(currentProfile: models.CurrentProfile, sessionId: String): Boolean = { customAwait(repo.collection.countDocuments().head())(defaultTimeout) diff --git a/test/controllers/errors/ThirdAttemptLockoutControllerSpec.scala b/test/controllers/errors/BankDetailsLockoutControllerSpec.scala similarity index 79% rename from test/controllers/errors/ThirdAttemptLockoutControllerSpec.scala rename to test/controllers/errors/BankDetailsLockoutControllerSpec.scala index 1d5ac5965..4e0173f6a 100644 --- a/test/controllers/errors/ThirdAttemptLockoutControllerSpec.scala +++ b/test/controllers/errors/BankDetailsLockoutControllerSpec.scala @@ -18,18 +18,19 @@ package controllers.errors import org.mockito.ArgumentMatchers.any import org.mockito.Mockito._ +import play.api.mvc.Result import play.api.test.FakeRequest import testHelpers.{ControllerSpec, FutureAssertions} -import views.html.errors.ThirdAttemptLockoutPage +import views.html.errors.BankDetailsLockoutPage import scala.concurrent.Future -class ThirdAttemptLockoutControllerSpec extends ControllerSpec with FutureAssertions { +class BankDetailsLockoutControllerSpec extends ControllerSpec with FutureAssertions { - val view: ThirdAttemptLockoutPage = app.injector.instanceOf[ThirdAttemptLockoutPage] + val view: BankDetailsLockoutPage = app.injector.instanceOf[BankDetailsLockoutPage] trait Setup { - val testController = new ThirdAttemptLockoutController( + val testController = new BankDetailsLockoutController( messagesControllerComponents, view, mockAuthConnector @@ -44,7 +45,7 @@ class ThirdAttemptLockoutControllerSpec extends ControllerSpec with FutureAssert "show" should { "return 200 and render the lockout page" in new Setup { - val result = testController.show()(FakeRequest()) + val result: Future[Result] = testController.show()(FakeRequest()) status(result) mustBe OK contentType(result) mustBe Some("text/html") } diff --git a/test/services/LockServiceSpec.scala b/test/services/LockServiceSpec.scala index bd354b5c9..0c7f7c249 100644 --- a/test/services/LockServiceSpec.scala +++ b/test/services/LockServiceSpec.scala @@ -22,77 +22,72 @@ import org.mockito.Mockito._ import org.scalatestplus.mockito.MockitoSugar import org.scalatestplus.play.PlaySpec import play.api.test.{DefaultAwaitTimeout, FutureAwaits} -import repositories.UserLockRepository +import repositories.BarsLockRepository import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.Future class LockServiceSpec extends PlaySpec with MockitoSugar with FutureAwaits with DefaultAwaitTimeout { - val mockUserLockRepository: UserLockRepository = mock[UserLockRepository] + val mockBarsLockRepository: BarsLockRepository = mock[BarsLockRepository] val mockAppConfig: FrontendAppConfig = mock[FrontendAppConfig] val registrationId = "reg-123" trait Setup { - val service: LockService = new LockService(mockUserLockRepository, mockAppConfig) + val service: LockService = new LockService(mockBarsLockRepository) } - // ---- getBarsAttemptsUsed ---- - "getBarsAttemptsUsed" should { "return the number of failed attempts from the repository" in new Setup { - when(mockUserLockRepository.getFailedAttempts(eqTo(registrationId))) + when(mockBarsLockRepository.getAttemptsUsed(eqTo(registrationId))) .thenReturn(Future.successful(2)) await(service.getBarsAttemptsUsed(registrationId)) mustBe 2 } "return 0 when there are no recorded attempts" in new Setup { - when(mockUserLockRepository.getFailedAttempts(eqTo(registrationId))) + when(mockBarsLockRepository.getAttemptsUsed(eqTo(registrationId))) .thenReturn(Future.successful(0)) await(service.getBarsAttemptsUsed(registrationId)) mustBe 0 } } - // ---- incrementBarsAttempts ---- "incrementBarsAttempts" should { "return the new total number of failed attempts after incrementing" in new Setup { - when(mockUserLockRepository.updateAttempts(eqTo(registrationId))) - .thenReturn(Future.successful(Map("user" -> 1))) + when(mockBarsLockRepository.recordFailedAttempt(eqTo(registrationId))) + .thenReturn(Future.successful(1)) await(service.incrementBarsAttempts(registrationId)) mustBe 1 } - "return 0 if the repository map does not contain the 'user' key" in new Setup { - when(mockUserLockRepository.updateAttempts(eqTo(registrationId))) - .thenReturn(Future.successful(Map.empty[String, Int])) + "return 1 as default if repository returns nothing" in new Setup { + when(mockBarsLockRepository.recordFailedAttempt(eqTo(registrationId))) + .thenReturn(Future.successful(1)) - await(service.incrementBarsAttempts(registrationId)) mustBe 0 + await(service.incrementBarsAttempts(registrationId)) mustBe 1 } "return 3 on the third failed attempt" in new Setup { - when(mockUserLockRepository.updateAttempts(eqTo(registrationId))) - .thenReturn(Future.successful(Map("user" -> 3))) + when(mockBarsLockRepository.recordFailedAttempt(eqTo(registrationId))) + .thenReturn(Future.successful(3)) await(service.incrementBarsAttempts(registrationId)) mustBe 3 } } - // ---- isBarsLocked ---- - "isBarsLocked" should { "return true when the user is locked in the repository" in new Setup { - when(mockUserLockRepository.isUserLocked(eqTo(registrationId))) + when(mockBarsLockRepository.isLocked(eqTo(registrationId))) .thenReturn(Future.successful(true)) await(service.isBarsLocked(registrationId)) mustBe true } "return false when the user is not locked in the repository" in new Setup { - when(mockUserLockRepository.isUserLocked(eqTo(registrationId))) + when(mockBarsLockRepository.isLocked(eqTo(registrationId))) .thenReturn(Future.successful(false)) await(service.isBarsLocked(registrationId)) mustBe false