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