Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/config/FrontendAppConfig.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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]]

Expand Down Expand Up @@ -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")
Expand Down
54 changes: 54 additions & 0 deletions app/controllers/bankdetails/AccountDetailsNotVerified.scala
Original file line number Diff line number Diff line change
@@ -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))
}
}
}
}
33 changes: 21 additions & 12 deletions app/controllers/bankdetails/UkBankAccountDetailsController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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 {
Expand All @@ -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)
}
}
}
}
)
Expand Down
42 changes: 42 additions & 0 deletions app/controllers/errors/ThirdAttemptLockoutController.scala
Original file line number Diff line number Diff line change
@@ -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()))
}
}
}
31 changes: 31 additions & 0 deletions app/models/Lock.scala
Original file line number Diff line number Diff line change
@@ -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
}
108 changes: 108 additions & 0 deletions app/repositories/UserLockRepository.scala
Original file line number Diff line number Diff line change
@@ -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
)
}
}
}
62 changes: 62 additions & 0 deletions app/services/LockService.scala
Original file line number Diff line number Diff line change
@@ -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)
}
Loading