Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
4 changes: 4 additions & 0 deletions app/connectors/ConstructionIndustrySchemeConnector.scala
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,8 @@ class ConstructionIndustrySchemeConnector @Inject() (config: ServicesConfig, htt
clientListJson.get
}

def getAgentClientTaxpayer(uniqueId: String)(implicit hc: HeaderCarrier): Future[CisTaxpayer] =
http
.get(url"$cisBaseUrl/agent/client/$uniqueId/taxpayer")
.execute[CisTaxpayer]
}
56 changes: 39 additions & 17 deletions app/controllers/agent/AgentLandingController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -18,36 +18,58 @@ package controllers.agent

import controllers.actions.*
import config.FrontendAppConfig
import play.api.Logging

import javax.inject.{Inject, Named}
import play.api.i18n.{I18nSupport, MessagesApi}
import play.api.mvc.{Action, AnyContent, MessagesControllerComponents}
import services.ManageService
import uk.gov.hmrc.http.HeaderCarrier
import uk.gov.hmrc.play.bootstrap.frontend.controller.FrontendBaseController
import uk.gov.hmrc.play.http.HeaderCarrierConverter
import views.html.agent.AgentLandingView

import scala.concurrent.ExecutionContext

class AgentLandingController @Inject() (
override val messagesApi: MessagesApi,
@Named("AgentIdentifier") identify: IdentifierAction,
getData: DataRetrievalAction,
requireData: DataRequiredAction,
manageService: ManageService,
val controllerComponents: MessagesControllerComponents,
view: AgentLandingView,
appConfig: FrontendAppConfig
) extends FrontendBaseController
with I18nSupport {
def onPageLoad: Action[AnyContent] = (identify andThen getData) { implicit request =>
implicit val config: FrontendAppConfig = appConfig
)(implicit ec: ExecutionContext)
extends FrontendBaseController
with I18nSupport
with Logging {

def onPageLoad(uniqueId: String): Action[AnyContent] =
(identify andThen getData andThen requireData).async { implicit request =>
implicit val config: FrontendAppConfig = appConfig
implicit val hc: HeaderCarrier = HeaderCarrierConverter.fromRequestAndSession(request, request.session)

Ok(
view(
clientName = "ABC Construction Ltd",
employerRef = "123/AB45678",
utr = "1234567890",
returnsDueCount = 1,
returnsDueBy = java.time.LocalDate.of(2025, 10, 19),
newNoticesCount = 2,
lastSubmittedDate = java.time.LocalDate.of(2025, 9, 19),
lastSubmittedTaxMonth = java.time.YearMonth.of(2025, 8)
)
)
}
manageService
.getAgentLandingData(uniqueId, request.userAnswers)
.map { viewModel =>
Ok(
view(
clientName = viewModel.clientName,
employerRef = viewModel.employerRef,
utr = viewModel.utr.getOrElse(""),
// still hard-coded, mocked for now
returnsDueCount = 1,
returnsDueBy = java.time.LocalDate.of(2025, 10, 19),
newNoticesCount = 2,
lastSubmittedDate = java.time.LocalDate.of(2025, 9, 19),
lastSubmittedTaxMonth = java.time.YearMonth.of(2025, 8)
)
)
}
.recover { case e =>
logger.error(s"[AgentLandingController][onPageLoad] Failed for uniqueId=$uniqueId", e)
Redirect(controllers.routes.JourneyRecoveryController.onPageLoad())
}
}
}
3 changes: 2 additions & 1 deletion app/models/CisTaxpayer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ case class CisTaxpayerSearchResult(
taxOfficeNumber: String,
taxOfficeRef: String,
agentOwnRef: Option[String],
schemeName: Option[String]
schemeName: Option[String],
utr: Option[String]
)

object CisTaxpayerSearchResult {
Expand Down
33 changes: 33 additions & 0 deletions app/services/ManageService.scala
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import play.api.Logging
import play.api.libs.json.Json
import repositories.SessionRepository
import uk.gov.hmrc.http.HeaderCarrier
import viewmodels.agent.AgentLandingViewModel

import javax.inject.{Inject, Singleton}
import scala.concurrent.{ExecutionContext, Future}
Expand Down Expand Up @@ -68,4 +69,36 @@ class ManageService @Inject() (
_ <- sessionRepository.set(updatedAnswers)
} yield (clients, updatedAnswers)
}

def getAgentLandingData(
uniqueId: String,
ua: UserAnswers
)(using HeaderCarrier): Future[AgentLandingViewModel] =
ua.get(AgentClientsPage) match {
case None =>
Future.failed(new RuntimeException("AgentClientsPage missing in UserAnswers"))

case Some(clients) =>
clients.find(_.uniqueId == uniqueId) match {
case None =>
Future.failed(new RuntimeException(s"Client with uniqueId=$uniqueId not found in AgentClientsPage"))

case Some(client) =>
cisConnector.getAgentClientTaxpayer(uniqueId).flatMap { taxpayer =>
val utrOpt = taxpayer.utr
val updatedClient = client.copy(utr = utrOpt)
val updatedList =
clients.map(c => if (c.uniqueId == client.uniqueId) updatedClient else c)

for {
updatedUa <- Future.fromTry(ua.set(AgentClientsPage, updatedList))
_ <- sessionRepository.set(updatedUa)
} yield AgentLandingViewModel(
clientName = updatedClient.schemeName.getOrElse(""),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just want to check, should the clientName be the schemeName?

Copy link
Contributor Author

@yanshengzhangHMRC yanshengzhangHMRC Nov 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

I think so as per confirmation from Laiba before, link for above pic is F6

employerRef = s"${updatedClient.taxOfficeNumber}/${updatedClient.taxOfficeRef}",
utr = utrOpt
)
}
}
}
}
23 changes: 23 additions & 0 deletions app/viewmodels/agent/AgentLandingViewModel.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* Copyright 2025 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 viewmodels.agent

case class AgentLandingViewModel(
clientName: String,
employerRef: String,
utr: Option[String]
)
9 changes: 8 additions & 1 deletion app/viewmodels/agent/ClientListViewModel.scala
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import viewmodels.Link
import viewmodels.agent.SearchBy.*

case class ClientListViewModel(
uniqueId: String,
clientName: String,
employerReference: String,
clientReference: String,
Expand All @@ -38,7 +39,12 @@ case class ClientListViewModel(
case _ => None
}
def clientLink(implicit messages: Messages): Option[Link] =
Some(Link("", ""))
Some(
Link(
"",
controllers.agent.routes.AgentLandingController.onPageLoad(uniqueId).url
)
)
}

object ClientListViewModel {
Expand All @@ -58,6 +64,7 @@ object ClientListViewModel {
def fromCisClients(clients: List[CisTaxpayerSearchResult]): Seq[ClientListViewModel] =
clients.map { client =>
ClientListViewModel(
uniqueId = client.uniqueId,
clientName = client.schemeName.getOrElse(""),
employerReference = s"${client.taxOfficeNumber}/${client.taxOfficeRef}",
clientReference = client.agentOwnRef.getOrElse(""),
Expand Down
15 changes: 10 additions & 5 deletions app/views/agent/AgentLandingView.scala.html
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Richy, updated now

Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,19 @@
)

@layout(
pageTitle = s"${messages("agent.landing.title", clientName)} - GOV.UK",
pageTitle = titleNoForm(messages("agent.landing.title")),
showBackLink = false,
beforeContent = Some(
Html(
s"""<a href="#" class="govuk-back-link" aria-disabled="true" onclick="return false;" tabindex="-1">${messages("agent.landing.back")}</a>"""
)
),
sidebar = Some(helpAndGuidelineSidebar())
s"""
|<a href="${controllers.agent.routes.ClientListSearchController.onPageLoad().url}"
| class="govuk-back-link">
| ${messages("agent.landing.back")}
|</a>
|""".stripMargin
)
),
sidebar = Some(helpAndGuidelineSidebar())
) {

@caption(clientName)
Expand Down
2 changes: 1 addition & 1 deletion conf/app.routes
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ GET /unauthorised/agent controllers.
GET /agent/retrieve-client-list controllers.agent.RetrievingClientController.onPageLoad()
GET /agent/no-client-list controllers.agent.FailedToRetrieveClientController.onPageLoad()
GET /agent/no-authorised-clients controllers.agent.NoAuthorisedClientsController.onPageLoad()
GET /agent/authorised-client-manage-CIS-returns controllers.agent.AgentLandingController.onPageLoad()
GET /agent/cis-return-dashboard/:uniqueId controllers.agent.AgentLandingController.onPageLoad(uniqueId: String)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our we ok to expose the uniqueId in the url ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding uniqueId as we need something that distinguish different agent clients. If we don't want uniqueId exposed in url, we can refactor the code to use index as we have all agent clients persisted in Mongo.

From the security side, I assume this should be fine. If the agent changes the uniqueId in the url from "123" (authorised) to "456"(unauthorised), we still have the guard in BE to ensure that only client authorised agent can view the landing page for a certain client. So agent would not see client's landing page whose uniqueId is "456".

Also uniqueId is not itself PII like utr or NINO or other sensitive info like tax office no. & ref that are actually exposed in the url in the as-is java service.


GET /agent/file-monthly-cis-returns controllers.agent.ClientListSearchController.onPageLoad()
POST /agent/file-monthly-cis-returns controllers.agent.ClientListSearchController.onSubmit()
Expand Down
4 changes: 2 additions & 2 deletions conf/messages.en
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,8 @@ contractorLanding.sidebar.nav.whatIs.text = What is the Construction Industry Sc
contractorLanding.sidebar.nav.guidance.text = CIS monthly returns guidance
contractorLanding.sidebar.nav.penalties.text = CIS 340: Penalties for late returns

agent.landing.title = Construction Industry Scheme for {0}
agent.landing.h1 = Manage CIS return
agent.landing.title = CIS return dashboard
agent.landing.h1 = CIS return dashboard
agent.landing.intro = You can manage {0}&#8217;s monthly CIS returns – including dates and statements.
agent.landing.employerRef.key = Employer reference
agent.landing.utr.key = Unique Taxpayer Reference (UTR)
Expand Down
68 changes: 68 additions & 0 deletions it/test/connectors/ConstructionIndustrySchemeConnectorSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -290,4 +290,72 @@ class ConstructionIndustrySchemeConnectorSpec extends AnyWordSpec
}
}

"getAgentClientTaxpayer" should {

"return CisTaxpayer when BE returns 200 with valid JSON" in {
val uniqueId = "123"

stubFor(
get(urlPathEqualTo(s"/cis/agent/client/$uniqueId/taxpayer"))
.willReturn(
aResponse()
.withStatus(OK)
.withBody(
"""{
| "uniqueId": "123",
| "taxOfficeNumber": "111",
| "taxOfficeRef": "test111",
| "employerName1": "TEST LTD"
|}""".stripMargin
)
)
)

val result = connector.getAgentClientTaxpayer(uniqueId).futureValue

result.uniqueId mustBe "123"
result.taxOfficeNumber mustBe "111"
result.taxOfficeRef mustBe "test111"
result.employerName1 mustBe Some("TEST LTD")
}

"fail when BE returns 200 with invalid JSON" in {
val uniqueId = "ABC-INVALID"

stubFor(
get(urlPathEqualTo(s"/cis/agent/client/$uniqueId/taxpayer"))
.willReturn(
aResponse()
.withStatus(OK)
.withBody("""{ "unexpectedField": true }""")
)
)

val ex = intercept[Exception] {
connector.getAgentClientTaxpayer(uniqueId).futureValue
}

ex.getMessage.toLowerCase must include("uniqueid")
}

"propagate an upstream error when BE returns 500" in {
val uniqueId = "500"

stubFor(
get(urlPathEqualTo(s"/cis/agent/client/$uniqueId/taxpayer"))
.willReturn(
aResponse()
.withStatus(INTERNAL_SERVER_ERROR)
.withBody("boom")
)
)

val ex = intercept[Exception] {
connector.getAgentClientTaxpayer(uniqueId).futureValue
}

ex.getMessage must include("returned 500")
}
}

}
Original file line number Diff line number Diff line change
@@ -1,11 +1,26 @@
/*
* Copyright 2025 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

import base.SpecBase
import play.api.test.FakeRequest
import play.api.test.Helpers.*
import viewmodels.SuccessfulAutomaticSubcontractorUpdateViewModel
import views.html.SuccessfulAutomaticSubcontractorUpdateView
import org.mockito.Mockito.*

class SuccessfulAutomaticSubcontractorUpdateControllerSpec extends SpecBase {

Expand Down
Loading