Skip to content
Closed
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
13 changes: 13 additions & 0 deletions app/connectors/ConstructionIndustrySchemeConnector.scala
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

package connectors

import models.amend.CreateAmendedMonthlyReturnRequest
import models.monthlyreturns.*
import models.requests.{GetMonthlyReturnForEditRequest, SendSuccessEmailRequest}
import models.submission.*
Expand Down Expand Up @@ -234,4 +235,16 @@ class ConstructionIndustrySchemeConnector @Inject() (config: ServicesConfig, htt
}
}

def createAmendedMonthlyReturn(request: CreateAmendedMonthlyReturnRequest)(implicit hc: HeaderCarrier): Future[Unit] =
http
.post(url"$cisBaseUrl/amend-monthly-return/create")
.withBody(Json.toJson(request))
.execute[HttpResponse]
.flatMap { response =>
response.status match {
case CREATED => Future.unit
case status => Future.failed(UpstreamErrorResponse(response.body, status, status))
}
}

}
202 changes: 191 additions & 11 deletions app/controllers/amend/ConfirmAmendmentController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -17,38 +17,218 @@
package controllers.amend

import controllers.actions.*
import pages.amend.ConfirmAmendmentPage
import models.ReturnType.{MonthlyNilReturn, MonthlyStandardReturn}
import models.{ReturnType, UserAnswers}
import models.amend.{AmendmentDetails, CreateAmendedMonthlyReturnRequest}
import models.monthlyreturns.{ContinueReturnJourneyQueryParams, MonthlyReturn, Submission}
import models.requests.OptionalDataRequest
import pages.agent.AgentClientDataPage
import pages.amend.{AmendmentDetailsPage, ConfirmAmendmentPage}
import pages.monthlyreturns.CisIdPage
import play.api.Logging
import play.api.i18n.{I18nSupport, MessagesApi}
import play.api.mvc.{Action, AnyContent, MessagesControllerComponents}
import repositories.SessionRepository
import services.{AmendMonthlyReturnService, MonthlyReturnService}
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.amend.ConfirmAmendmentView

import javax.inject.Inject
import scala.concurrent.ExecutionContext
import scala.concurrent.{ExecutionContext, Future}

class ConfirmAmendmentController @Inject() (
override val messagesApi: MessagesApi,
identify: IdentifierAction,
getData: DataRetrievalAction,
requireData: DataRequiredAction,
sessionRepository: SessionRepository,
amendMonthlyReturnService: AmendMonthlyReturnService,
monthlyReturnService: MonthlyReturnService,
val controllerComponents: MessagesControllerComponents,
view: ConfirmAmendmentView
)(implicit ec: ExecutionContext)
extends FrontendBaseController
with I18nSupport {
with I18nSupport
with Logging {

def onPageLoad: Action[AnyContent] = (identify andThen getData andThen requireData) { implicit request =>
Ok(view())
}
def onPageLoad(queryParams: ContinueReturnJourneyQueryParams): Action[AnyContent] =
(identify andThen getData).async { implicit request =>
implicit val hc: HeaderCarrier = HeaderCarrierConverter.fromRequestAndSession(request, request.session)

validateUserCanAccessRequest(queryParams.instanceId)
.flatMap {
case true =>
retrieveAndStoreAmendmentDetails(queryParams).map(_ => Ok(view()))

case false =>
logger.warn(
s"[ConfirmAmendmentController] User ${request.userId} attempted to access instanceId ${queryParams.instanceId} " +
s"which they are not authorised to access"
)
Future.successful(Redirect(controllers.routes.JourneyRecoveryController.onPageLoad()))
}
.recover { case ex =>
logger.warn(
s"[ConfirmAmendmentController] Failed to validate access for instanceId ${queryParams.instanceId}",
ex
)
Redirect(controllers.routes.JourneyRecoveryController.onPageLoad())
}
}

def onSubmit: Action[AnyContent] =
(identify andThen getData andThen requireData).async { implicit request =>
val updatedAnswers = request.userAnswers.set(ConfirmAmendmentPage, true).get
(identify andThen getData).async { implicit request =>
request.userAnswers.flatMap(_.get(AmendmentDetailsPage)) match {
case Some(amendmentDetails) =>
implicit val hc: HeaderCarrier = HeaderCarrierConverter.fromRequestAndSession(request, request.session)

val queryParams = ContinueReturnJourneyQueryParams(
instanceId = amendmentDetails.instanceId,
taxYear = amendmentDetails.taxYear,
taxMonth = amendmentDetails.taxMonth
)

validateUserCanAccessRequest(queryParams.instanceId)
.flatMap {
case true =>
val createRequest = CreateAmendedMonthlyReturnRequest(
instanceId = amendmentDetails.instanceId,
taxYear = amendmentDetails.taxYear,
taxMonth = amendmentDetails.taxMonth,
version = 0
)

for {
_ <- amendMonthlyReturnService.createAmendedMonthlyReturn(createRequest)
updatedAnswers =
request.userAnswers.getOrElse(UserAnswers(request.userId)).set(ConfirmAmendmentPage, true).get
_ <- sessionRepository.set(updatedAnswers)
} yield Redirect(
controllers.amend.routes.ConfirmAmendmentController.onPageLoad(queryParams)
) // TODO: DTR-4657

case false =>
logger.warn(
s"[ConfirmAmendmentController][onSubmit] User ${request.userId} attempted to submit for instanceId ${queryParams.instanceId} " +
s"which they are not authorised to access"
)
Future.successful(Redirect(controllers.routes.JourneyRecoveryController.onPageLoad()))
}
.recover { case ex =>
logger.warn(
s"[ConfirmAmendmentController][onSubmit] Failed to validate access for instanceId ${queryParams.instanceId}",
ex
)
Redirect(controllers.routes.JourneyRecoveryController.onPageLoad())
}

sessionRepository.set(updatedAnswers).map { _ =>
Redirect(controllers.amend.routes.ConfirmAmendmentController.onPageLoad())
case None =>
logger.warn(s"[ConfirmAmendmentController] AmendmentDetails missing from userAnswers")
Future.successful(Redirect(controllers.routes.JourneyRecoveryController.onPageLoad()))
}
}

private def validateUserCanAccessRequest(
instanceId: String
)(implicit request: OptionalDataRequest[_], hc: HeaderCarrier): Future[Boolean] =
if (request.isAgent) {
validateAgentCanAccessClient()
} else {
validateOrgCanAccessInstanceId(instanceId)
}

private def validateAgentCanAccessClient()(implicit
request: OptionalDataRequest[_],
hc: HeaderCarrier
): Future[Boolean] =
monthlyReturnService.getAgentClient(request.userId).flatMap {
case Some(agentClientData) =>
val updatedUserAnswers =
request.userAnswers
.getOrElse(UserAnswers(request.userId))
.set(AgentClientDataPage, agentClientData)
.get

sessionRepository.set(updatedUserAnswers).flatMap { _ =>
monthlyReturnService.hasClient(agentClientData.taxOfficeNumber, agentClientData.taxOfficeReference)
}

case None =>
logger.warn(s"[ConfirmAmendmentController] Agent user ${request.userId} has no AgentClientData")
Future.successful(false)
}

private def validateOrgCanAccessInstanceId(instanceId: String)(implicit hc: HeaderCarrier): Future[Boolean] =
monthlyReturnService.getCisTaxpayer().map { taxPayer =>
val authorised = taxPayer.uniqueId == instanceId

if (!authorised) {
logger.warn(
s"[ConfirmAmendmentController] Organisation user attempted to access instanceId $instanceId " +
s"which does not match their uniqueId ${taxPayer.uniqueId}"
)
}

authorised
}

private def retrieveAndStoreAmendmentDetails(
params: ContinueReturnJourneyQueryParams
)(implicit request: OptionalDataRequest[_], hc: HeaderCarrier): Future[Unit] =
monthlyReturnService
.retrieveMonthlyReturnForEditDetails(
instanceId = params.instanceId,
taxMonth = params.taxMonth,
taxYear = params.taxYear
)
.flatMap { details =>
details.monthlyReturn.find(matchesQueryParams(_, params)) match {
case Some(monthlyReturn) =>
val amendmentDetails = toAmendmentDetails(params, monthlyReturn, details.submission)
val updatedUserAnswers =
request.userAnswers
.getOrElse(UserAnswers(request.userId))
.set(CisIdPage, params.instanceId)
.flatMap(_.set(AmendmentDetailsPage, amendmentDetails))
.get

sessionRepository.set(updatedUserAnswers).map(_ => ())

case None =>
Future.failed(
new RuntimeException(
s"No monthly return found for instanceId ${params.instanceId}, taxMonth ${params.taxMonth}, taxYear ${params.taxYear}"
)
)
}
}

private def matchesQueryParams(monthlyReturn: MonthlyReturn, queryParams: ContinueReturnJourneyQueryParams): Boolean =
monthlyReturn.taxYear == queryParams.taxYear &&
monthlyReturn.taxMonth == queryParams.taxMonth

private def toAmendmentDetails(
params: ContinueReturnJourneyQueryParams,
monthlyReturn: MonthlyReturn,
submissions: Seq[Submission]
): AmendmentDetails =
AmendmentDetails(
instanceId = params.instanceId,
taxYear = params.taxYear,
taxMonth = params.taxMonth,
returnType = deriveReturnType(monthlyReturn),
acceptedTime = acceptedTimeForMonthlyReturn(monthlyReturn, submissions)
)

private def deriveReturnType(monthlyReturn: MonthlyReturn): ReturnType =
monthlyReturn.nilReturnIndicator match {
case Some("Y") => MonthlyNilReturn
case _ => MonthlyStandardReturn
}

private def acceptedTimeForMonthlyReturn(monthlyReturn: MonthlyReturn, submissions: Seq[Submission]): Option[String] =
submissions
.find(_.activeObjectId.contains(monthlyReturn.monthlyReturnId))
.flatMap(_.acceptedTime)
}
32 changes: 32 additions & 0 deletions app/models/amend/AmendmentDetails.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* 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.amend

import models.ReturnType
import play.api.libs.json.{Json, OFormat}

case class AmendmentDetails(
instanceId: String,
taxYear: Int,
taxMonth: Int,
returnType: ReturnType,
acceptedTime: Option[String]
)

object AmendmentDetails {
given format: OFormat[AmendmentDetails] = Json.format[AmendmentDetails]
}
30 changes: 30 additions & 0 deletions app/models/amend/CreateAmendedMonthlyReturnRequest.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* 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.amend

import play.api.libs.json.{Json, OFormat}

case class CreateAmendedMonthlyReturnRequest(
instanceId: String,
taxYear: Int,
taxMonth: Int,
version: Int
)

object CreateAmendedMonthlyReturnRequest {
given format: OFormat[CreateAmendedMonthlyReturnRequest] = Json.format[CreateAmendedMonthlyReturnRequest]
}
29 changes: 29 additions & 0 deletions app/pages/amend/AmendmentDetailsPage.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* 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 pages.amend

import models.amend.AmendmentDetails
import pages.QuestionPage
import play.api.libs.json.JsPath

case object AmendmentDetailsPage extends QuestionPage[AmendmentDetails] {

override def path: JsPath = JsPath \ toString

override def toString: String = "amendmentDetails"

}
32 changes: 32 additions & 0 deletions app/services/AmendMonthlyReturnService.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* 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 connectors.ConstructionIndustrySchemeConnector
import models.amend.CreateAmendedMonthlyReturnRequest
import uk.gov.hmrc.http.HeaderCarrier

import javax.inject.{Inject, Singleton}
import scala.concurrent.Future

@Singleton
class AmendMonthlyReturnService @Inject() (cisConnector: ConstructionIndustrySchemeConnector) {

def createAmendedMonthlyReturn(request: CreateAmendedMonthlyReturnRequest)(implicit hc: HeaderCarrier): Future[Unit] =
cisConnector.createAmendedMonthlyReturn(request)

}
3 changes: 3 additions & 0 deletions app/services/MonthlyReturnService.scala
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ class MonthlyReturnService @Inject() (
)(implicit ec: ExecutionContext)
extends Logging {

def getCisTaxpayer(implicit hc: HeaderCarrier): Future[CisTaxpayer] =
cisConnector.getCisTaxpayer()

def resolveAndStoreCisId(ua: UserAnswers, isAgent: Boolean)(implicit
hc: HeaderCarrier
): Future[(String, UserAnswers)] =
Expand Down
4 changes: 2 additions & 2 deletions conf/app.routes
Original file line number Diff line number Diff line change
Expand Up @@ -171,5 +171,5 @@ POST /monthly-return/cancel-amended-monthly-return
GET /amend-monthly-return/what-do-you-want-to-amend-nil controllers.amend.WhatDoYouWantToAmendNilController.onPageLoad()
POST /amend-monthly-return/what-do-you-want-to-amend-nil controllers.amend.WhatDoYouWantToAmendNilController.onSubmit()

GET /manage-cis-return/amend-monthly-return/confirm-amendment controllers.amend.ConfirmAmendmentController.onPageLoad()
POST /manage-cis-return/amend-monthly-return/confirm-amendment controllers.amend.ConfirmAmendmentController.onSubmit()
GET /manage-cis-return/amend-monthly-return/confirm-amendments controllers.amend.ConfirmAmendmentController.onPageLoad(queryParams: models.monthlyreturns.ContinueReturnJourneyQueryParams)
POST /manage-cis-return/amend-monthly-return/confirm-amendments controllers.amend.ConfirmAmendmentController.onSubmit()
Loading