Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
8 changes: 3 additions & 5 deletions app/connectors/ConstructionIndustrySchemeConnector.scala
Original file line number Diff line number Diff line change
Expand Up @@ -89,15 +89,13 @@ class ConstructionIndustrySchemeConnector @Inject() (config: ServicesConfig, htt
.withBody(Json.toJson(payload))
.execute[NilMonthlyReturnResponse]

def updateNilMonthlyReturn(
payload: NilMonthlyReturnRequest
)(implicit hc: HeaderCarrier): Future[Unit] =
def updateMonthlyReturn(payload: UpdateMonthlyReturnRequest)(implicit hc: HeaderCarrier): Future[Unit] =
http
.post(url"$cisBaseUrl/monthly-returns/nil/update")
.post(url"$cisBaseUrl/monthly-returns/update")
.withBody(Json.toJson(payload))
.execute[HttpResponse]
.flatMap { response =>
if (response.status / 100 == 2) {
if (response.status == 204) {
Future.unit
} else {
Future.failed(UpstreamErrorResponse(response.body, response.status, response.status))
Expand Down
45 changes: 26 additions & 19 deletions app/controllers/monthlyreturns/CheckYourAnswersController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
package controllers.monthlyreturns

import controllers.actions.*
import pages.monthlyreturns.NilReturnStatusPage
import models.monthlyreturns.UpdateMonthlyReturnRequest
import pages.monthlyreturns.ReturnTypePage
import play.api.i18n.{I18nSupport, MessagesApi}
import play.api.mvc.{Action, AnyContent, MessagesControllerComponents}
import services.MonthlyReturnService
Expand Down Expand Up @@ -68,32 +69,38 @@ class CheckYourAnswersController @Inject() (
}

def onSubmit(): Action[AnyContent] = (identify andThen getData andThen requireData).async { implicit request =>
request.userAnswers.get(NilReturnStatusPage) match {
request.userAnswers.get(ReturnTypePage) match {
case None =>
logger.warn(
"[CheckYourAnswersController] C6 submit without FormP record (missing NilReturnStatusPage); redirecting to journey recovery"
"[CheckYourAnswersController] C6 submit without FormP record (missing ReturnTypePage); redirecting to journey recovery"
)
Future.successful(Redirect(controllers.routes.JourneyRecoveryController.onPageLoad()))

case Some(_) =>
case Some(returnType) =>
implicit val hc: HeaderCarrier =
HeaderCarrierConverter.fromRequestAndSession(request, request.session)

monthlyReturnService
.updateNilMonthlyReturn(request.userAnswers)
.map { _ =>
logger.info(
"[CheckYourAnswersController] Updated FormP monthly nil return confirmation/nil flags; redirecting to submission"
)
Redirect(controllers.monthlyreturns.routes.SubmissionSendingController.onPageLoad())
}
.recover { case ex =>
logger.error(
s"[CheckYourAnswersController] Failed to update FormP monthly nil return: ${ex.getMessage}",
ex
)
Redirect(controllers.routes.SystemErrorController.onPageLoad())
}
val updateRequest = UpdateMonthlyReturnRequest.fromUserAnswers(request.userAnswers)

updateRequest match {
case Left(error) =>
logger.error(s"[CheckYourAnswersController] Failed to build update request: $error")
Future.successful(InternalServerError)

case Right(req) =>
monthlyReturnService
.updateMonthlyReturn(req)
.map { _ =>
logger.info(
s"[CheckYourAnswersController] Successfully updated monthly return ($returnType), redirecting to submission"
)
Redirect(controllers.monthlyreturns.routes.SubmissionSendingController.onPageLoad())
}
.recover { case t =>
logger.error("[CheckYourAnswersController] Failed to update monthly return ($returnType)", t)
Redirect(controllers.routes.SystemErrorController.onPageLoad())
}
}
}
}
}
113 changes: 113 additions & 0 deletions app/models/monthlyreturns/UpdateMonthlyReturnRequest.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/*
* 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.monthlyreturns

import models.ReturnType.{MonthlyNilReturn, MonthlyStandardReturn}
import models.{ReturnType, UserAnswers}
import pages.monthlyreturns.*
import play.api.libs.json.{Json, OFormat}

import java.time.LocalDate

case class UpdateMonthlyReturnRequest(
instanceId: String,
taxYear: Int,
taxMonth: Int,
amendment: String,
decEmpStatusConsidered: Option[String] = None,
decAllSubsVerified: Option[String] = None,
decNoMoreSubPayments: Option[String] = None,
decNilReturnNoPayments: Option[String] = None,
decInformationCorrect: Option[String] = None,
nilReturnIndicator: String,
status: String,
version: Option[Long] = None
)

object UpdateMonthlyReturnRequest {

implicit val format: OFormat[UpdateMonthlyReturnRequest] = Json.format[UpdateMonthlyReturnRequest]

private def toYN(value: Boolean): String = if (value) "Y" else "N"

private def dateFor(returnType: ReturnType, ua: UserAnswers): Either[String, LocalDate] =
returnType match {
case MonthlyNilReturn => ua.get(DateConfirmNilPaymentsPage).toRight("Missing date for nil return")
case MonthlyStandardReturn => ua.get(DateConfirmPaymentsPage).toRight("Missing date for standard return")
}

private def inactivityY(returnType: ReturnType, ua: UserAnswers): Option[String] =
returnType match {
case MonthlyStandardReturn =>
ua.get(SubmitInactivityRequestPage) match {
case Some(true) => Some("Y")
case Some(false) => None
case None => None
}

case MonthlyNilReturn =>
ua.get(InactivityRequestPage).collect { case InactivityRequest.Option1 => "Y" }
}

private def nilIndicator(returnType: ReturnType): String =
returnType match {
case MonthlyNilReturn => "Y"
case MonthlyStandardReturn => "N"
}

def fromUserAnswers(ua: UserAnswers): Either[String, UpdateMonthlyReturnRequest] =
for {
returnType <- ua.get(ReturnTypePage).toRight("Missing return type")
instanceId <- ua.get(CisIdPage).toRight("Missing instanceId")
date <- dateFor(returnType, ua)

decInformationCorrect = returnType match {
case MonthlyNilReturn =>
ua.get(DeclarationPage).flatMap { declaration =>
if (declaration.nonEmpty) Some("Y") else None
}

case MonthlyStandardReturn =>
ua.get(PaymentDetailsConfirmationPage).map(toYN)
}
} yield {
val base = UpdateMonthlyReturnRequest(
instanceId = instanceId,
taxYear = date.getYear,
taxMonth = date.getMonthValue,
amendment = "N",
decInformationCorrect = decInformationCorrect,
nilReturnIndicator = nilIndicator(returnType),
status = "STARTED",
version = None
)

returnType match {
case MonthlyStandardReturn =>
base.copy(
decEmpStatusConsidered = ua.get(EmploymentStatusDeclarationPage).map(toYN),
decAllSubsVerified = ua.get(VerifiedStatusDeclarationPage).map(toYN),
decNoMoreSubPayments = inactivityY(returnType, ua)
)

case MonthlyNilReturn =>
base.copy(
decNilReturnNoPayments = inactivityY(returnType, ua)
)
}
}
}
23 changes: 2 additions & 21 deletions app/services/MonthlyReturnService.scala
Original file line number Diff line number Diff line change
Expand Up @@ -114,27 +114,8 @@ class MonthlyReturnService @Inject() (
} yield saved
}

def updateNilMonthlyReturn(userAnswers: UserAnswers)(implicit hc: HeaderCarrier): Future[Unit] = {
logger.info("[MonthlyReturnService] Updating FormP monthly nil return confirmation/nil flags at C6")

for {
cisId <- getCisId(userAnswers)
year <- getTaxYear(userAnswers)
month <- getTaxMonth(userAnswers)
infoCorrect <- getInfoCorrectOrDefault(userAnswers)
nilNoPayments <- getNilNoPaymentsOrDefault(userAnswers)
_ <- {
val payload = NilMonthlyReturnRequest(
instanceId = cisId,
taxYear = year,
taxMonth = month,
decInformationCorrect = infoCorrect,
decNilReturnNoPayments = nilNoPayments
)
cisConnector.updateNilMonthlyReturn(payload)
}
} yield ()
}
def updateMonthlyReturn(request: UpdateMonthlyReturnRequest)(implicit hc: HeaderCarrier): Future[Unit] =
cisConnector.updateMonthlyReturn(request)

def createMonthlyReturn(request: MonthlyReturnRequest)(implicit hc: HeaderCarrier): Future[Unit] =
cisConnector.createMonthlyReturn(request)
Expand Down
50 changes: 49 additions & 1 deletion it/test/connectors/ConstructionIndustrySchemeConnectorSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ package connectors
import com.github.tomakehurst.wiremock.client.WireMock.*
import itutil.ApplicationWithWiremock
import models.requests.SendSuccessEmailRequest
import models.monthlyreturns.{DeleteMonthlyReturnItemRequest, MonthlyReturnRequest}
import models.monthlyreturns.*
import models.submission.{ChrisSubmissionRequest, CreateSubmissionRequest, UpdateSubmissionRequest}
import org.scalatest.concurrent.{IntegrationPatience, ScalaFutures}
import org.scalatest.matchers.must.Matchers
Expand Down Expand Up @@ -493,6 +493,54 @@ class ConstructionIndustrySchemeConnectorSpec extends AnyWordSpec
}
}

"updateMonthlyReturn(payload)" should {

"POST to /cis/monthly-returns/update and return Unit on 204" in {
val req = UpdateMonthlyReturnRequest(
instanceId = cisId,
taxYear = 2025,
taxMonth = 1,
amendment = "N",
decNilReturnNoPayments = Some("Y"),
decInformationCorrect = Some("Y"),
nilReturnIndicator = "Y",
status = "STARTED"
)

stubFor(
post(urlPathEqualTo("/cis/monthly-returns/update"))
.withHeader("Content-Type", equalTo("application/json"))
.withRequestBody(equalToJson(Json.toJson(req).toString(), true, true))
.willReturn(aResponse().withStatus(NO_CONTENT))
)

connector.updateMonthlyReturn(req).futureValue mustBe ((): Unit)
}

"fail the future with UpstreamErrorResponse on non-204 (e.g. 500)" in {
val req = UpdateMonthlyReturnRequest(
instanceId = cisId,
taxYear = 2025,
taxMonth = 1,
amendment = "N",
decNilReturnNoPayments = Some("Y"),
decInformationCorrect = Some("Y"),
nilReturnIndicator = "Y",
status = "STARTED"
)

stubFor(
post(urlPathEqualTo("/cis/monthly-returns/update"))
.willReturn(aResponse().withStatus(INTERNAL_SERVER_ERROR).withBody("boom"))
)

val err = connector.updateMonthlyReturn(req).failed.futureValue
err mustBe a[UpstreamErrorResponse]
err.asInstanceOf[UpstreamErrorResponse].statusCode mustBe INTERNAL_SERVER_ERROR
err.getMessage must include("boom")
}
}

"createSubmission" should {

"POST to /cis/submissions/create and return submissionId on 201" in {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import viewmodels.govuk.SummaryListFluency
import viewmodels.checkAnswers.monthlyreturns.{PaymentsToSubcontractorsSummary, ReturnTypeSummary}
import views.html.monthlyreturns.CheckYourAnswersView
import org.scalatestplus.mockito.MockitoSugar
import pages.monthlyreturns.{CisIdPage, DateConfirmNilPaymentsPage, NilReturnStatusPage, ReturnTypePage}
import pages.monthlyreturns.{CisIdPage, DateConfirmNilPaymentsPage, ReturnTypePage}

import java.time.LocalDate
import scala.concurrent.Future
Expand Down Expand Up @@ -99,20 +99,20 @@ class CheckYourAnswersControllerSpec extends SpecBase with SummaryListFluency wi
}
}

"must call updateNilMonthlyReturn and redirect to submission sending on POST when FormP record already exists (NilReturnStatusPage set)" in {
"must call updateMonthlyReturn and redirect to submission sending on POST when ReturnTypePage is present" in {
val userAnswers = emptyUserAnswers
.set(CisIdPage, "test-cis-id")
.success
.value
.set(DateConfirmNilPaymentsPage, LocalDate.of(2024, 3, 1))
.set(ReturnTypePage, ReturnType.MonthlyNilReturn)
.success
.value
.set(NilReturnStatusPage, "STARTED")
.set(DateConfirmNilPaymentsPage, LocalDate.of(2024, 3, 1))
.success
.value

val mockService = mock[MonthlyReturnService]
when(mockService.updateNilMonthlyReturn(any())(any()))
when(mockService.updateMonthlyReturn(any())(any()))
.thenReturn(Future.successful(()))

val application = applicationBuilder(userAnswers = Some(userAnswers))
Expand All @@ -131,7 +131,7 @@ class CheckYourAnswersControllerSpec extends SpecBase with SummaryListFluency wi
}
}

"must redirect to journey recovery on POST when NilReturnStatusPage is missing" in {
"must redirect to journey recovery on POST when ReturnTypePage is missing" in {

val application = applicationBuilder(userAnswers = Some(emptyUserAnswers)).build()

Expand All @@ -144,5 +144,55 @@ class CheckYourAnswersControllerSpec extends SpecBase with SummaryListFluency wi
redirectLocation(result).value mustEqual controllers.routes.JourneyRecoveryController.onPageLoad().url
}
}

"must return InternalServerError on POST when update request cannot be built" in {
val userAnswers = emptyUserAnswers
.set(ReturnTypePage, ReturnType.MonthlyNilReturn)
.success
.value

val mockService = mock[MonthlyReturnService]

val application = applicationBuilder(userAnswers = Some(userAnswers))
.overrides(bind[MonthlyReturnService].toInstance(mockService))
.build()

running(application) {
val request = FakeRequest(POST, controllers.monthlyreturns.routes.CheckYourAnswersController.onSubmit().url)
val result = route(application, request).value

status(result) mustEqual INTERNAL_SERVER_ERROR
}
}

"must redirect to system error on POST when updateMonthlyReturn fails" in {
val userAnswers = emptyUserAnswers
.set(CisIdPage, "test-cis-id")
.success
.value
.set(ReturnTypePage, ReturnType.MonthlyNilReturn)
.success
.value
.set(DateConfirmNilPaymentsPage, LocalDate.of(2024, 3, 1))
.success
.value

val mockService = mock[MonthlyReturnService]
when(mockService.updateMonthlyReturn(any())(any()))
.thenReturn(Future.failed(new RuntimeException("boom")))

val application = applicationBuilder(userAnswers = Some(userAnswers))
.overrides(bind[MonthlyReturnService].toInstance(mockService))
.build()

running(application) {
val request = FakeRequest(POST, controllers.monthlyreturns.routes.CheckYourAnswersController.onSubmit().url)

val result = route(application, request).value

status(result) mustEqual SEE_OTHER
redirectLocation(result).value mustEqual controllers.routes.SystemErrorController.onPageLoad().url
}
}
}
}
Loading