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/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
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
+ }
+ }
+}
+