diff --git a/app/config/Module.scala b/app/config/Module.scala index 6041569c..54578917 100644 --- a/app/config/Module.scala +++ b/app/config/Module.scala @@ -21,7 +21,7 @@ import com.google.inject.name.Names import controllers.actions.* import services.{MonthlyReturnItemPayloadBuilder, MonthlyReturnItemPayloadBuilderImpl} import utils.{ReferenceGenerator, ReferenceGeneratorImpl} -import services.guard.{DuplicateMRCreationGuard, DuplicateMRCreationGuardImpl} +import services.guard.{DuplicateMRCreationGuard, DuplicateMRCreationGuardImpl, SubmissionSuccessfulServiceGuard, SubmissionSuccessfulServiceGuardImpl} import java.time.{Clock, ZoneOffset} @@ -45,6 +45,7 @@ class Module extends AbstractModule { .to(classOf[AgentIdentifierAction]) .asEagerSingleton() bind(classOf[DuplicateMRCreationGuard]).to(classOf[DuplicateMRCreationGuardImpl]) + bind(classOf[SubmissionSuccessfulServiceGuard]).to(classOf[SubmissionSuccessfulServiceGuardImpl]) bind(classOf[Clock]).toInstance(Clock.systemDefaultZone.withZone(ZoneOffset.UTC)) bind(classOf[MonthlyReturnItemPayloadBuilder]).to(classOf[MonthlyReturnItemPayloadBuilderImpl]) } diff --git a/app/controllers/monthlyreturns/SubmissionSuccessController.scala b/app/controllers/monthlyreturns/SubmissionSuccessController.scala index d6418774..786280a6 100644 --- a/app/controllers/monthlyreturns/SubmissionSuccessController.scala +++ b/app/controllers/monthlyreturns/SubmissionSuccessController.scala @@ -28,6 +28,7 @@ import models.requests.DataRequest import play.api.i18n.{I18nSupport, MessagesApi} import play.api.mvc.{Action, AnyContent, MessagesControllerComponents} import services.MonthlyReturnService +import services.guard.SubmissionSuccessfulServiceGuard import uk.gov.hmrc.http.HeaderCarrier import uk.gov.hmrc.play.http.HeaderCarrierConverter import uk.gov.hmrc.play.bootstrap.frontend.controller.FrontendBaseController @@ -49,7 +50,8 @@ class SubmissionSuccessController @Inject() ( val controllerComponents: MessagesControllerComponents, view: SubmissionSuccessView, clock: Clock, - monthlyReturnService: MonthlyReturnService + monthlyReturnService: MonthlyReturnService, + submissionSuccessGuard: SubmissionSuccessfulServiceGuard )(implicit ec: ExecutionContext, appConfig: FrontendAppConfig) extends FrontendBaseController with I18nSupport @@ -60,12 +62,15 @@ class SubmissionSuccessController @Inject() ( implicit val hc: HeaderCarrier = HeaderCarrierConverter.fromRequestAndSession(request, request.session) - val ua = request.userAnswers - - for { - vm <- buildViewModel(ua) - _ <- monthlyReturnService.completeSubmissionJourney(ua) - } yield Ok(view(vm)) + if (!submissionSuccessGuard.check) { + Future.successful(Redirect(controllers.routes.JourneyRecoveryController.onPageLoad())) + } else { + val ua = request.userAnswers + for { + vm <- buildViewModel(ua) + _ <- monthlyReturnService.completeSubmissionJourney(ua) + } yield Ok(view(vm)) + } } private def buildViewModel(ua: UserAnswers)(implicit diff --git a/app/models/submission/SubmissionDetails.scala b/app/models/submission/SubmissionDetails.scala index 3c1cd4dd..69acc2b9 100644 --- a/app/models/submission/SubmissionDetails.scala +++ b/app/models/submission/SubmissionDetails.scala @@ -24,7 +24,9 @@ case class SubmissionDetails( id: String, status: String, irMark: String, - submittedAt: LocalDateTime + submittedAt: LocalDateTime, + amendment: Option[String] = None, + hmrcMarkGgis: Option[String] = None ) object SubmissionDetails { diff --git a/app/services/guard/SubmissionSuccessfulServiceGuard.scala b/app/services/guard/SubmissionSuccessfulServiceGuard.scala new file mode 100644 index 00000000..3bfd5235 --- /dev/null +++ b/app/services/guard/SubmissionSuccessfulServiceGuard.scala @@ -0,0 +1,47 @@ +/* + * 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 services.guard + +import models.requests.DataRequest +import pages.submission.SubmissionDetailsPage +import play.api.Logging + +import javax.inject.Singleton + +trait SubmissionSuccessfulServiceGuard { + def check(implicit request: DataRequest[_]): Boolean +} + +@Singleton +class SubmissionSuccessfulServiceGuardImpl extends SubmissionSuccessfulServiceGuard with Logging { + + def check(implicit request: DataRequest[_]): Boolean = + request.userAnswers.get(SubmissionDetailsPage).exists { details => + val submittedOrAmendment = details.status == "SUBMITTED" || details.amendment.contains("Y") + val irMarksValid = details.irMark.nonEmpty && + details.hmrcMarkGgis.exists(g => g.nonEmpty && g == details.irMark) + + if (!submittedOrAmendment) + logger.warn( + s"[SubmissionSuccessfulServiceGuard] Guard failed: status=${details.status}, amendment=${details.amendment}" + ) + if (!irMarksValid) + logger.warn(s"[SubmissionSuccessfulServiceGuard] Guard failed: irMark empty or hmrcMarkGgis mismatch") + + submittedOrAmendment && irMarksValid + } +} diff --git a/app/services/submission/SubmissionService.scala b/app/services/submission/SubmissionService.scala index d6336a78..c93d2960 100644 --- a/app/services/submission/SubmissionService.scala +++ b/app/services/submission/SubmissionService.scala @@ -87,10 +87,11 @@ class SubmissionService @Inject() ( cisConnector.getCisTaxpayer() for { - taxpayer <- taxpayerFut - csr <- chrisRequestBuilder.build(ua, taxpayer, isAgent)(hc) - response <- cisConnector.submitToChris(submissionId, csr) - _ <- writeToFeMongo(ua, submissionId, response) + taxpayer <- taxpayerFut + csr <- chrisRequestBuilder.build(ua, taxpayer, isAgent)(hc) + response <- cisConnector.submitToChris(submissionId, csr) + amendment <- fetchAmendmentFlag(ua) + _ <- writeToFeMongo(ua, submissionId, response, amendment) } yield response def updateSubmissionFromChrisResponse( @@ -204,34 +205,38 @@ class SubmissionService @Inject() ( } yield "TIMED_OUT" } else { for { - cisId <- userAnswers.get(CisIdPage).toFuture - pollUrl <- userAnswers.get(PollUrlPage).toFuture - submissionDetails <- userAnswers.get(SubmissionDetailsPage).toFuture - submissionId <- userAnswers.get(SubmissionDetailsPage).map(_.id).toFuture - result <- cisConnector.getSubmissionStatus(pollUrl, submissionId) - _ <- updateSubmission( - submissionDetails.id, - userAnswers, - submissionDetails.irMark, - result.status, - Some(dateFormatter.format(submissionDetails.submittedAt)), - result.irMarkReceived, - result.error - ) - newStatus = result.status - timedOut = + pollUrl <- userAnswers.get(PollUrlPage).toFuture + submissionId <- userAnswers.get(SubmissionDetailsPage).map(_.id).toFuture + result <- cisConnector.getSubmissionStatus(pollUrl, submissionId) + _ <- updateSubmission( + submissionDetails.id, + userAnswers, + submissionDetails.irMark, + result.status, + Some(dateFormatter.format(submissionDetails.submittedAt)), + result.irMarkReceived, + result.error + ) + newStatus = result.status + timedOut = LocalDateTime.now().isAfter(timeoutDateTime) && (newStatus == "ACCEPTED" || newStatus == "PENDING") - finalStatus = if (timedOut) "TIMED_OUT" else newStatus - newDetails = submissionDetails.copy(status = newStatus) - ua1 <- Future.fromTry(userAnswers.set(SubmissionDetailsPage, newDetails)) - ua2 <- Future.fromTry(ua1.set(SubmissionStatusTimedOutPage(submissionDetails.id), timedOut)) - ua3 <- result.pollUrl.map(url => ua2.set(PollUrlPage, url)).getOrElse(Try(ua2)).toFuture - ua4 <- result.intervalSeconds.map(i => ua3.set(PollIntervalPage, i)).getOrElse(Try(ua3)).toFuture - ua5 <- result.lastMessageDate match { - case Some(ts) => Future.fromTry(ua4.set(LastMessageDatePage, Instant.parse(ts))) - case None => Future.successful(ua4) - } - _ <- sessionRepository.set(ua5) + finalStatus = if (timedOut) "TIMED_OUT" else newStatus + irMarkValidated = newStatus == "SUBMITTED" + newDetails = submissionDetails.copy( + status = newStatus, + hmrcMarkGgis = result.irMarkReceived.orElse( + if (irMarkValidated) Some(submissionDetails.irMark) else submissionDetails.hmrcMarkGgis + ) + ) + ua1 <- Future.fromTry(userAnswers.set(SubmissionDetailsPage, newDetails)) + ua2 <- Future.fromTry(ua1.set(SubmissionStatusTimedOutPage(submissionDetails.id), timedOut)) + ua3 <- result.pollUrl.map(url => ua2.set(PollUrlPage, url)).getOrElse(Try(ua2)).toFuture + ua4 <- result.intervalSeconds.map(i => ua3.set(PollIntervalPage, i)).getOrElse(Try(ua3)).toFuture + ua5 <- result.lastMessageDate match { + case Some(ts) => Future.fromTry(ua4.set(LastMessageDatePage, Instant.parse(ts))) + case None => Future.successful(ua4) + } + _ <- sessionRepository.set(ua5) } yield finalStatus } } @@ -268,10 +273,11 @@ class SubmissionService @Inject() ( emailOpt match { case None => - val updated = userAnswers.set(SuccessEmailSentPage(submissionId), true) - Future - .fromTry(updated) - .flatMap(updatedUa => sessionRepository.set(updatedUa).map(_ => updatedUa)) + for { + latestUa <- sessionRepository.get(userAnswers.id).map(_.getOrElse(userAnswers)) + updatedUa <- Future.fromTry(latestUa.set(SuccessEmailSentPage(submissionId), true)) + _ <- sessionRepository.set(updatedUa) + } yield updatedUa case Some(email) => val locale: Locale = Lang.get(langCode).map(_.locale).getOrElse(Locale.UK) @@ -281,11 +287,10 @@ class SubmissionService @Inject() ( year = yearMonth.getYear.toString ) - val updatedUaFuture = Future.fromTry(userAnswers.set(SuccessEmailSentPage(submissionId), true)) - for { _ <- cisConnector.sendSuccessfulEmail(submissionId, request) - updatedUa <- updatedUaFuture + latestUa <- sessionRepository.get(userAnswers.id).map(_.getOrElse(userAnswers)) + updatedUa <- Future.fromTry(latestUa.set(SuccessEmailSentPage(submissionId), true)) _ <- sessionRepository.set(updatedUa) } yield updatedUa } @@ -324,10 +329,23 @@ class SubmissionService @Inject() ( throw new RuntimeException("Date of return missing for monthly return") ) + private def fetchAmendmentFlag(ua: UserAnswers)(implicit hc: HeaderCarrier): Future[Option[String]] = { + val instanceId = ua.get(CisIdPage).getOrElse(throw new RuntimeException("CIS ID missing")) + val ym = selectedYearMonth(ua) + cisConnector + .retrieveMonthlyReturnForEditDetails(instanceId, ym.getMonthValue, ym.getYear) + .map(_.monthlyReturn.headOption.flatMap(_.amendment)) + .recover { case ex => + logger.warn("[fetchAmendmentFlag] Failed to retrieve amendment flag, defaulting to None", ex) + None + } + } + private def writeToFeMongo( ua: UserAnswers, submissionId: String, - response: ChrisSubmissionResponse + response: ChrisSubmissionResponse, + amendment: Option[String] ): Future[Boolean] = { val updatedUa: Try[UserAnswers] = for { ua1 <- ua.set( @@ -338,7 +356,9 @@ class SubmissionService @Inject() ( irMark = response.hmrcMarkGenerated, submittedAt = response.gatewayTimestamp .flatMap(t => Try(LocalDateTime.parse(t)).toOption) - .getOrElse(LocalDateTime.now) + .getOrElse(LocalDateTime.now), + amendment = amendment, + hmrcMarkGgis = None ) ) ua2 <- response.responseEndPoint match { diff --git a/test/controllers/amend/ConfirmCancelAmendmentYesNoControllerSpec.scala b/test/controllers/amend/ConfirmCancelAmendmentYesNoControllerSpec.scala index 1c750c94..dd806610 100644 --- a/test/controllers/amend/ConfirmCancelAmendmentYesNoControllerSpec.scala +++ b/test/controllers/amend/ConfirmCancelAmendmentYesNoControllerSpec.scala @@ -1,3 +1,19 @@ +/* + * 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.amend import base.SpecBase diff --git a/test/controllers/monthlyreturns/SubmissionSuccessControllerSpec.scala b/test/controllers/monthlyreturns/SubmissionSuccessControllerSpec.scala index 2ed11b91..73c5de24 100644 --- a/test/controllers/monthlyreturns/SubmissionSuccessControllerSpec.scala +++ b/test/controllers/monthlyreturns/SubmissionSuccessControllerSpec.scala @@ -23,6 +23,7 @@ import models.agent.AgentClientData import models.submission.SubmissionDetails import org.mockito.Mockito.* import org.mockito.ArgumentMatchers.{any, eq as eqTo} +import org.scalatest.BeforeAndAfterEach import pages.agent.AgentClientDataPage import pages.monthlyreturns.{ContractorNamePage, DateConfirmPaymentsPage, EnterYourEmailAddressPage, ReturnTypePage} import pages.submission.SubmissionDetailsPage @@ -32,33 +33,36 @@ import play.api.test.Helpers.* import play.api.inject.bind import play.api.mvc.AnyContentAsEmpty import services.MonthlyReturnService +import services.guard.SubmissionSuccessfulServiceGuard import uk.gov.hmrc.http.HeaderCarrier -import views.html.monthlyreturns.SubmissionSuccessView import utils.IrMarkReferenceGenerator -import viewmodels.checkAnswers.monthlyreturns.SubmissionSuccessViewModel import java.time.format.DateTimeFormatter import java.time.{Clock, Instant, LocalDate, LocalDateTime, ZoneId, ZoneOffset, ZonedDateTime} import java.util.Locale import scala.concurrent.Future -class SubmissionSuccessControllerSpec extends SpecBase { +class SubmissionSuccessControllerSpec extends SpecBase with BeforeAndAfterEach { - val email: String = "test@test.com" - val periodEnd: LocalDate = LocalDate.of(2018, 3, 5) - val fixedInstant: Instant = Instant.parse("2017-01-06T08:46:00Z") - val irMarkBase64: String = "Pyy1LRJh053AE+nuyp0GJR7oESw=" - val reference: String = IrMarkReferenceGenerator.fromBase64(irMarkBase64) - val contractorName: String = "PAL 355 Scheme" - val employerRef: String = "taxOfficeNumber/taxOfficeReference" - val submissionType: ReturnType = ReturnType.MonthlyNilReturn - val cisId = "1" + val email: String = "test@test.com" + val periodEnd: LocalDate = LocalDate.of(2018, 3, 5) + val fixedInstant: Instant = Instant.parse("2017-01-06T08:46:00Z") + val irMarkBase64: String = "Pyy1LRJh053AE+nuyp0GJR7oESw=" + val reference: String = IrMarkReferenceGenerator.fromBase64(irMarkBase64) + val contractorName: String = "PAL 355 Scheme" + val employerRef: String = "taxOfficeNumber/taxOfficeReference" private val monthYearFmt = DateTimeFormatter.ofPattern("MMMM uuuu").withLocale(Locale.UK) private val fullDateFmt = DateTimeFormatter.ofPattern("d MMMM uuuu").withLocale(Locale.UK) private val timeFmt = DateTimeFormatter.ofPattern("h:mma").withLocale(Locale.UK) private val london = ZoneId.of("Europe/London") private val mockMonthlyReturnService = mock(classOf[MonthlyReturnService]) + private val mockGuard = mock(classOf[SubmissionSuccessfulServiceGuard]) + + override def beforeEach(): Unit = { + super.beforeEach() + reset(mockMonthlyReturnService, mockGuard) + } protected lazy val ukNow: ZonedDateTime = ZonedDateTime.ofInstant(fixedInstant, london) @@ -87,435 +91,396 @@ class SubmissionSuccessControllerSpec extends SpecBase { .success .value + lazy val agentDate: AgentClientData = + AgentClientData("CLIENT-123", "taxOfficeNumber", "taxOfficeReference", Some("PAL 355 Scheme")) + lazy val request: FakeRequest[AnyContentAsEmpty.type] = FakeRequest(GET, routes.SubmissionSuccessController.onPageLoad.url) - lazy val view: SubmissionSuccessView = app.injector.instanceOf[SubmissionSuccessView] - - lazy val expectedHtml: String = - view( - SubmissionSuccessViewModel( - reference = reference, - periodEnd = periodEnd.format(monthYearFmt), - submittedTime = submittedTime, - submittedDate = submittedDate, - contractorName = contractorName, - empRef = employerRef, - email = email, - submissionType = submissionType, - cisId = cisId - ) - )(request, applicationConfig, messages(app)).toString - lazy val agentDate: AgentClientData = - AgentClientData("CLIENT-123", "taxOfficeNumber", "taxOfficeReference", Some("PAL 355 Scheme")) + private def buildApp( + userAnswers: UserAnswers, + isAgent: Boolean = false, + hasEmployeeRef: Boolean = true, + hasAgentRef: Boolean = true + ): Application = + applicationBuilder( + userAnswers = Some(userAnswers), + isAgent = isAgent, + hasEmployeeRef = hasEmployeeRef, + hasAgentRef = hasAgentRef + ) + .overrides( + bind[Clock].toInstance(Clock.fixed(fixedInstant, ZoneOffset.UTC)), + bind[MonthlyReturnService].toInstance(mockMonthlyReturnService), + bind[SubmissionSuccessfulServiceGuard].toInstance(mockGuard) + ) + .build() "SubmissionSuccessController" - { "contractor" - { - "onPageLoad" - { + val userAnswersWithReturnType = ua + .set(ReturnTypePage, ReturnType.MonthlyNilReturn) + .success + .value + + "must return OK and render key fields" in { + when(mockGuard.check(any())).thenReturn(true) + when(mockMonthlyReturnService.completeSubmissionJourney(any[UserAnswers])(any[HeaderCarrier])) + .thenReturn(Future.unit) + + val app = buildApp(userAnswersWithReturnType) + + running(app) { + val result = route(app, request).value + status(result) mustBe OK + val body = contentAsString(result) + body must include(periodEnd.format(monthYearFmt)) + body must include(submittedDate) + body must include(contractorName) + body must include(employerRef) + body must include(email) + } + } + + "must not call getSchemeEmail when email is present in user answers" in { + when(mockGuard.check(any())).thenReturn(true) + when(mockMonthlyReturnService.completeSubmissionJourney(any[UserAnswers])(any[HeaderCarrier])) + .thenReturn(Future.unit) - val userAnswersWithReturnType = ua + val app = buildApp(userAnswersWithReturnType) + + running(app) { + val result = route(app, request).value + status(result) mustBe OK + verify(mockMonthlyReturnService, never()).getSchemeEmail(any())(any()) + } + } + + "must redirect to Unauthorised Organisation Affinity if cisId is not found in UserAnswer" in { + val app = applicationBuilder(userAnswers = Some(emptyUserAnswers)).build() + + running(app) { + val result = route(app, request).value + status(result) mustEqual SEE_OTHER + redirectLocation(result).value mustEqual controllers.routes.UnauthorisedOrganisationAffinityController + .onPageLoad() + .url + } + } + + "must call getSchemeEmail and use returned email when EnterYourEmailAddressPage is missing" in { + val fallbackEmail = "fallback@test.com" + val uaWithoutEmail = userAnswersWithCisId + .set(ContractorNamePage, contractorName) + .success + .value .set(ReturnTypePage, ReturnType.MonthlyNilReturn) .success .value + .set(DateConfirmPaymentsPage, periodEnd) + .success + .value + .set( + SubmissionDetailsPage, + SubmissionDetails(id = "123", status = "ACCEPTED", irMark = irMarkBase64, submittedAt = LocalDateTime.now) + ) + .success + .value - lazy val app: Application = - applicationBuilder(userAnswers = Some(userAnswersWithReturnType)) - .overrides(bind[Clock].toInstance(Clock.fixed(fixedInstant, ZoneOffset.UTC))) - .build() + when(mockGuard.check(any())).thenReturn(true) + when(mockMonthlyReturnService.getSchemeEmail(eqTo("1"))(any[HeaderCarrier])) + .thenReturn(Future.successful(Some(fallbackEmail))) + when(mockMonthlyReturnService.completeSubmissionJourney(any[UserAnswers])(any[HeaderCarrier])) + .thenReturn(Future.unit) - "must return OK and render the expected view" in { - running(app) { - val result = route(app, request).value + val app = buildApp(uaWithoutEmail) - status(result) mustBe OK - contentAsString(result) mustBe expectedHtml - } + running(app) { + val result = route(app, request).value + status(result) mustBe OK + contentAsString(result) must include(fallbackEmail) + verify(mockMonthlyReturnService).getSchemeEmail(eqTo("1"))(any[HeaderCarrier]) } + } - "must redirect to Unauthorised Organisation Affinity if cisId is not found in UserAnswer" in { + "must default to empty email if getSchemeEmail fails" in { + val uaWithoutEmail = userAnswersWithCisId + .set(ContractorNamePage, contractorName) + .success + .value + .set(ReturnTypePage, ReturnType.MonthlyNilReturn) + .success + .value + .set(DateConfirmPaymentsPage, periodEnd) + .success + .value + .set( + SubmissionDetailsPage, + SubmissionDetails(id = "123", status = "ACCEPTED", irMark = irMarkBase64, submittedAt = LocalDateTime.now) + ) + .success + .value - val app = applicationBuilder(userAnswers = Some(emptyUserAnswers)).build() + when(mockGuard.check(any())).thenReturn(true) + when(mockMonthlyReturnService.getSchemeEmail(any())(any())) + .thenReturn(Future.failed(new RuntimeException("boom"))) + when(mockMonthlyReturnService.completeSubmissionJourney(any[UserAnswers])(any[HeaderCarrier])) + .thenReturn(Future.unit) - running(app) { + val app = buildApp(uaWithoutEmail) - val result = route(app, request).value + running(app) { + val result = route(app, request).value + status(result) mustBe OK + verify(mockMonthlyReturnService).getSchemeEmail(eqTo("1"))(any[HeaderCarrier]) + } + } - status(result) mustEqual SEE_OTHER + "must throw if ReturnTypePage is missing" in { + when(mockGuard.check(any())).thenReturn(true) + val app = buildApp(ua) - redirectLocation( - result - ).value mustEqual controllers.routes.UnauthorisedOrganisationAffinityController.onPageLoad().url + running(app) { + val thrown = intercept[IllegalStateException] { + await(route(app, request).get) } + thrown.getMessage must include("[SubmissionSuccess] ReturnTypePage missing from userAnswers") } + } - "must throw if ReturnTypePage is missing" in { - val incompleteUa = ua // note: ua does not set ReturnTypePage + "must throw if contractorName is missing" in { + when(mockGuard.check(any())).thenReturn(true) + val incompleteUa = userAnswersWithCisId + .set(ReturnTypePage, MonthlyNilReturn) + .success + .value + .set(EnterYourEmailAddressPage, email) + .success + .value + .set(DateConfirmPaymentsPage, periodEnd) + .success + .value + .set( + SubmissionDetailsPage, + SubmissionDetails(id = "123", status = "ACCEPTED", irMark = irMarkBase64, submittedAt = LocalDateTime.now) + ) + .success + .value - val app = applicationBuilder(userAnswers = Some(incompleteUa)).build() + val app = buildApp(incompleteUa) - running(app) { - val thrown = intercept[IllegalStateException] { - await(route(app, request).get) - } - thrown.getMessage must include("[SubmissionSuccess] ReturnTypePage missing from userAnswers") + running(app) { + val thrown = intercept[IllegalStateException] { + await(route(app, request).get) } + thrown.getMessage must include("contractorName missing for userId=") } + } - "must use scheme email when email is missing from user answers" in { - val uaWithoutEmail = userAnswersWithCisId - .set(ContractorNamePage, contractorName) - .success - .value - .set(ReturnTypePage, ReturnType.MonthlyNilReturn) - .success - .value - .set(DateConfirmPaymentsPage, periodEnd) - .success - .value - .set( - SubmissionDetailsPage, - SubmissionDetails(id = "123", status = "ACCEPTED", irMark = irMarkBase64, submittedAt = LocalDateTime.now) - ) - .success - .value - - when(mockMonthlyReturnService.getSchemeEmail(eqTo("1"))(any[HeaderCarrier])) - .thenReturn(Future.successful(Some(email))) - - when(mockMonthlyReturnService.completeSubmissionJourney(any[UserAnswers])(any[HeaderCarrier])) - .thenReturn(Future.unit) - - val app = - applicationBuilder(userAnswers = Some(uaWithoutEmail)) - .overrides( - bind[Clock].toInstance(Clock.fixed(fixedInstant, ZoneOffset.UTC)), - bind[MonthlyReturnService].toInstance(mockMonthlyReturnService) - ) - .build() - - running(app) { - val result = route(app, request).value - - status(result) mustBe OK - contentAsString(result) must include(email) - - verify(mockMonthlyReturnService).getSchemeEmail(eqTo("1"))(any[HeaderCarrier]) - verify(mockMonthlyReturnService).completeSubmissionJourney(any[UserAnswers])(any[HeaderCarrier]) - } - } + "must throw if employerReference is missing" in { + when(mockGuard.check(any())).thenReturn(true) + val app = buildApp(userAnswersWithReturnType, hasEmployeeRef = false) - "must throw if contractorName is missing" in { - val incompleteUa = userAnswersWithCisId - .set(ReturnTypePage, MonthlyNilReturn) - .success - .value - .set(EnterYourEmailAddressPage, email) - .success - .value - .set(DateConfirmPaymentsPage, periodEnd) - .success - .value - .set( - SubmissionDetailsPage, - SubmissionDetails(id = "123", status = "ACCEPTED", irMark = irMarkBase64, submittedAt = LocalDateTime.now) - ) - .success - .value - - val app = applicationBuilder(userAnswers = Some(incompleteUa)).build() - - running(app) { - val thrown = intercept[IllegalStateException] { - await(route(app, request).get) - } - thrown.getMessage must include("contractorName missing for userId=") + running(app) { + val thrown = intercept[IllegalStateException] { + await(route(app, request).get) } + thrown.getMessage must include("employerReference missing for userId=") } + } - "must throw if employerReference is missing" in { - val incompleteUa = userAnswersWithCisId - .set(ReturnTypePage, MonthlyNilReturn) - .success - .value - .set(EnterYourEmailAddressPage, email) - .success - .value - .set(ContractorNamePage, contractorName) - .success - .value - .set(DateConfirmPaymentsPage, periodEnd) - .success - .value - .set( - SubmissionDetailsPage, - SubmissionDetails(id = "123", status = "ACCEPTED", irMark = irMarkBase64, submittedAt = LocalDateTime.now) - ) - .success - .value - - val app = applicationBuilder(userAnswers = Some(incompleteUa), hasEmployeeRef = false).build() - running(app) { - val thrown = intercept[IllegalStateException] { - await(route(app, request).get) - } - thrown.getMessage must include("employerReference missing for userId=") - } - } + "must throw if taxPeriodEnd is missing" in { + when(mockGuard.check(any())).thenReturn(true) + val incompleteUa = userAnswersWithCisId + .set(ReturnTypePage, MonthlyNilReturn) + .success + .value + .set(ContractorNamePage, contractorName) + .success + .value + .set(EnterYourEmailAddressPage, "test@test.com") + .success + .value + .set( + SubmissionDetailsPage, + SubmissionDetails(id = "123", status = "ACCEPTED", irMark = irMarkBase64, submittedAt = LocalDateTime.now) + ) + .success + .value - "must throw if taxPeriodEnd is missing" in { - val incompleteUa = userAnswersWithCisId - .set(ReturnTypePage, MonthlyNilReturn) - .success - .value - .set(ContractorNamePage, contractorName) - .success - .value - .set(EnterYourEmailAddressPage, "test@test.com") - .success - .value - .set( - SubmissionDetailsPage, - SubmissionDetails(id = "123", status = "ACCEPTED", irMark = irMarkBase64, submittedAt = LocalDateTime.now) - ) - .success - .value - - val app = applicationBuilder(userAnswers = Some(incompleteUa)).build() - running(app) { - val thrown = intercept[IllegalStateException] { - await(route(app, request).get) - } - thrown.getMessage must include("[SubmissionSuccess] taxPeriodEnd missing from userAnswers") - } - } + val app = buildApp(incompleteUa) - "must throw if submissionDetails is missing" in { - val incompleteUa = userAnswersWithCisId - .set(ReturnTypePage, MonthlyNilReturn) - .success - .value - .set(ContractorNamePage, contractorName) - .success - .value - .set(DateConfirmPaymentsPage, periodEnd) - .success - .value - .set(EnterYourEmailAddressPage, "test@test.com") - .success - .value - - val app = applicationBuilder(userAnswers = Some(incompleteUa)).build() - running(app) { - val thrown = intercept[IllegalStateException] { - await(route(app, request).get) - } - thrown.getMessage must include("[SubmissionSuccess] submissionDetails missing from userAnswers") + running(app) { + val thrown = intercept[IllegalStateException] { + await(route(app, request).get) } + thrown.getMessage must include("[SubmissionSuccess] taxPeriodEnd missing from userAnswers") } + } - "must throw if returnTypePage is missing" in { - - val incompleteUa = - userAnswersWithCisId - .set(ContractorNamePage, contractorName) - .success - .value - .set(DateConfirmPaymentsPage, periodEnd) - .success - .value - .set(EnterYourEmailAddressPage, email) - .success - .value - .set( - SubmissionDetailsPage, - SubmissionDetails( - id = "123", - status = "ACCEPTED", - irMark = irMarkBase64, - submittedAt = LocalDateTime.now - ) - ) - .success - .value - - val app = applicationBuilder(userAnswers = Some(incompleteUa)).build() - - running(app) { - val thrown = intercept[IllegalStateException] { - await(route(app, request).get) - } - thrown.getMessage must include("ReturnTypePage missing from userAnswers") - } + "must redirect to JourneyRecovery when guard fails" in { + when(mockGuard.check(any())).thenReturn(false) + val app = buildApp(userAnswersWithReturnType) + + running(app) { + val result = route(app, request).value + status(result) mustBe SEE_OTHER + redirectLocation(result).value mustBe controllers.routes.JourneyRecoveryController.onPageLoad().url } + } - "must call monthlyReturnService and use returned email when EnterYourEmailAddressPage is missing" in { - - val fallbackEmail = "fallback@test.com" - - val uaWithoutEmail: UserAnswers = ua - .remove(EnterYourEmailAddressPage) - .success - .value - .set(ReturnTypePage, ReturnType.MonthlyNilReturn) - .success - .value - - val mockService = mock(classOf[MonthlyReturnService]) - - when(mockService.getSchemeEmail(any())(any())) - .thenReturn(Future.successful(Some(fallbackEmail))) - - when(mockService.completeSubmissionJourney(any[UserAnswers])(any[HeaderCarrier])) - .thenReturn(Future.unit) - - val app = - applicationBuilder(userAnswers = Some(uaWithoutEmail)) - .overrides( - bind[Clock].toInstance(Clock.fixed(fixedInstant, ZoneOffset.UTC)), - bind[MonthlyReturnService].toInstance(mockService) - ) - .build() - - val view = app.injector.instanceOf[SubmissionSuccessView] - - lazy val expectedHtml: String = - view( - SubmissionSuccessViewModel( - reference = reference, - periodEnd = periodEnd.format(monthYearFmt), - submittedTime = submittedTime, - submittedDate = submittedDate, - contractorName = contractorName, - empRef = employerRef, - email = fallbackEmail, - submissionType = submissionType, - cisId = cisId - ) - )(request, applicationConfig, messages(app)).toString - - running(app) { - val result = route(app, request).value - - status(result) mustBe OK - contentAsString(result) mustBe expectedHtml - } + "must redirect to JourneyRecovery when submission details are missing (guard fails)" in { + when(mockGuard.check(any())).thenReturn(false) + val uaNoDetails = userAnswersWithCisId + .set(ReturnTypePage, MonthlyNilReturn) + .success + .value - verify(mockService).getSchemeEmail(any())(any()) - verify(mockService).completeSubmissionJourney(any[UserAnswers])(any[HeaderCarrier]) - } + val app = buildApp(uaNoDetails) + running(app) { + val result = route(app, request).value + status(result) mustBe SEE_OTHER + redirectLocation(result).value mustBe controllers.routes.JourneyRecoveryController.onPageLoad().url + } } } "agent" - { - "onPageLoad" - { + val userAnswersWithAgentClientData = ua + .set(AgentClientDataPage, agentDate) + .success + .value + + val userAnswersWithReturnType = userAnswersWithAgentClientData + .set(ReturnTypePage, ReturnType.MonthlyNilReturn) + .success + .value + + "must return OK and render key fields using AgentClientData" in { + when(mockGuard.check(any())).thenReturn(true) + when(mockMonthlyReturnService.completeSubmissionJourney(any[UserAnswers])(any[HeaderCarrier])) + .thenReturn(Future.unit) + + val app = buildApp(userAnswersWithReturnType, isAgent = true) + + running(app) { + val result = route(app, request).value + status(result) mustBe OK + val body = contentAsString(result) + body must include(contractorName) + body must include(employerRef) + } + } - val userAnswersWithAgentClientData = ua - .set(AgentClientDataPage, agentDate) + "must redirect to Unauthorised Agent Affinity if cisId is not found in UserAnswer" in { + val app = applicationBuilder(userAnswers = Some(emptyUserAnswers), isAgent = true).build() + + running(app) { + val result = route(app, request).value + status(result) mustEqual SEE_OTHER + redirectLocation(result).value mustEqual controllers.routes.UnauthorisedAgentAffinityController + .onPageLoad() + .url + } + } + + "must throw if contractorName is missing" in { + when(mockGuard.check(any())).thenReturn(true) + val incompleteUa = userAnswersWithCisId + .set(ReturnTypePage, MonthlyNilReturn) .success .value - - val userAnswersWithReturnType = userAnswersWithAgentClientData - .set(ReturnTypePage, ReturnType.MonthlyNilReturn) + .set(EnterYourEmailAddressPage, email) + .success + .value + .set(DateConfirmPaymentsPage, periodEnd) + .success + .value + .set( + SubmissionDetailsPage, + SubmissionDetails(id = "123", status = "ACCEPTED", irMark = irMarkBase64, submittedAt = LocalDateTime.now) + ) .success .value - lazy val app: Application = - applicationBuilder(userAnswers = Some(userAnswersWithReturnType), isAgent = true) - .overrides(bind[Clock].toInstance(Clock.fixed(fixedInstant, ZoneOffset.UTC))) - .build() - - "must return OK and render the expected view" in { - running(app) { - val result = route(app, request).value + val app = buildApp(incompleteUa, isAgent = true) - status(result) mustBe OK - contentAsString(result) mustBe expectedHtml + running(app) { + val thrown = intercept[IllegalStateException] { + await(route(app, request).get) } + thrown.getMessage must include("contractorName missing for userId=") } + } - "must redirect to Unauthorised Agent Affinity if cisId is not found in UserAnswer" in { - - val app = applicationBuilder(userAnswers = Some(emptyUserAnswers), isAgent = true).build() - - running(app) { + "must throw if agent employerReference is missing" in { + when(mockGuard.check(any())).thenReturn(true) + lazy val agentDateWithoutTaxRefTaxNumber: AgentClientData = + AgentClientData("CLIENT-123", "", "taxOfficeReference", Some("PAL 355 Scheme")) - val result = route(app, request).value + val incompleteUa = userAnswersWithCisId + .set(ReturnTypePage, MonthlyNilReturn) + .success + .value + .set(EnterYourEmailAddressPage, email) + .success + .value + .set(AgentClientDataPage, agentDateWithoutTaxRefTaxNumber) + .success + .value + .set(DateConfirmPaymentsPage, periodEnd) + .success + .value + .set( + SubmissionDetailsPage, + SubmissionDetails(id = "123", status = "ACCEPTED", irMark = irMarkBase64, submittedAt = LocalDateTime.now) + ) + .success + .value - status(result) mustEqual SEE_OTHER + val app = buildApp(incompleteUa, isAgent = true, hasAgentRef = false) - redirectLocation( - result - ).value mustEqual controllers.routes.UnauthorisedAgentAffinityController.onPageLoad().url + running(app) { + val thrown = intercept[IllegalStateException] { + await(route(app, request).get) } + thrown.getMessage must include("employerReference missing for userId=") } + } - "must throw if contractorName is missing" in { - val incompleteUa = userAnswersWithCisId - .set(ReturnTypePage, MonthlyNilReturn) - .success - .value - .set(EnterYourEmailAddressPage, email) - .success - .value - .set(DateConfirmPaymentsPage, periodEnd) - .success - .value - .set( - SubmissionDetailsPage, - SubmissionDetails(id = "123", status = "ACCEPTED", irMark = irMarkBase64, submittedAt = LocalDateTime.now) - ) - .success - .value - - val app = applicationBuilder(userAnswers = Some(incompleteUa), isAgent = true).build() - running(app) { - val thrown = intercept[IllegalStateException] { - await(route(app, request).get) - } - thrown.getMessage must include("contractorName missing for userId=") - } - } + "must call getSchemeEmail when email is missing for agent" in { + val fallbackEmail = "fallback@test.com" + when(mockGuard.check(any())).thenReturn(true) + val agentData = AgentClientData("CLIENT-123", "taxOfficeNumber", "taxOfficeReference", Some(contractorName)) + val agentUa = ua + .remove(EnterYourEmailAddressPage) + .success + .value + .set(ReturnTypePage, ReturnType.MonthlyNilReturn) + .success + .value + .set(AgentClientDataPage, agentData) + .success + .value - "must throw if employerReference is missing" in { - lazy val agentDateWithoutTaxRefTaxNumber: AgentClientData = - AgentClientData("CLIENT-123", "", "taxOfficeReference", Some("PAL 355 Scheme")) - - val incompleteUa = userAnswersWithCisId - .set(ReturnTypePage, MonthlyNilReturn) - .success - .value - .set(EnterYourEmailAddressPage, email) - .success - .value - .set(AgentClientDataPage, agentDateWithoutTaxRefTaxNumber) - .success - .value - .set(DateConfirmPaymentsPage, periodEnd) - .success - .value - .set( - SubmissionDetailsPage, - SubmissionDetails(id = "123", status = "ACCEPTED", irMark = irMarkBase64, submittedAt = LocalDateTime.now) - ) - .success - .value - - val app = applicationBuilder(userAnswers = Some(incompleteUa), isAgent = true, hasAgentRef = false).build() - running(app) { - val thrown = intercept[IllegalStateException] { - await(route(app, request).get) - } - thrown.getMessage must include("employerReference missing for userId=") - } - } + when(mockMonthlyReturnService.getSchemeEmail(eqTo("1"))(any[HeaderCarrier])) + .thenReturn(Future.successful(Some(fallbackEmail))) + when(mockMonthlyReturnService.completeSubmissionJourney(any[UserAnswers])(any[HeaderCarrier])) + .thenReturn(Future.unit) + + val app = buildApp(agentUa, isAgent = true) + running(app) { + val result = route(app, request).value + status(result) mustBe OK + contentAsString(result) must include(fallbackEmail) + verify(mockMonthlyReturnService).getSchemeEmail(eqTo("1"))(any[HeaderCarrier]) + } } } - } - } diff --git a/test/forms/amend/ConfirmCancelAmendmentYesNoFormProviderSpec.scala b/test/forms/amend/ConfirmCancelAmendmentYesNoFormProviderSpec.scala index 4c8500d6..06311158 100644 --- a/test/forms/amend/ConfirmCancelAmendmentYesNoFormProviderSpec.scala +++ b/test/forms/amend/ConfirmCancelAmendmentYesNoFormProviderSpec.scala @@ -1,3 +1,19 @@ +/* + * 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 forms.amend import forms.behaviours.BooleanFieldBehaviours diff --git a/test/services/guard/SubmissionSuccessfulServiceGuardSpec.scala b/test/services/guard/SubmissionSuccessfulServiceGuardSpec.scala new file mode 100644 index 00000000..60cd6e84 --- /dev/null +++ b/test/services/guard/SubmissionSuccessfulServiceGuardSpec.scala @@ -0,0 +1,122 @@ +/* + * 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 services.guard + +import models.UserAnswers +import models.requests.DataRequest +import models.submission.SubmissionDetails +import org.scalatest.matchers.must.Matchers +import org.scalatest.wordspec.AnyWordSpec +import pages.submission.SubmissionDetailsPage +import play.api.test.FakeRequest + +import java.time.LocalDateTime + +class SubmissionSuccessfulServiceGuardSpec extends AnyWordSpec with Matchers { + + private val guard = new SubmissionSuccessfulServiceGuardImpl + private val irMark = "Pyy1LRJh053AE+nuyp0GJR7oESw=" + + private def emptyUserAnswers(userId: String = "uid"): UserAnswers = + UserAnswers(userId) + + private def dataRequest(ua: UserAnswers): DataRequest[_] = + DataRequest( + request = FakeRequest(), + userId = ua.id, + userAnswers = ua, + employerReference = None + ) + + private def submissionDetails( + status: String = "SUBMITTED", + irMark: String = irMark, + amendment: Option[String] = None, + hmrcMarkGgis: Option[String] = Some(irMark) + ): SubmissionDetails = + SubmissionDetails( + id = "sub-1", + status = status, + irMark = irMark, + submittedAt = LocalDateTime.now(), + amendment = amendment, + hmrcMarkGgis = hmrcMarkGgis + ) + + "SubmissionSuccessfulServiceGuardImpl.check" should { + + "return false when SubmissionDetailsPage is absent" in { + implicit val request: DataRequest[_] = dataRequest(emptyUserAnswers()) + guard.check mustBe false + } + + "return true when status is SUBMITTED and IRMarks match" in { + val ua = emptyUserAnswers().set(SubmissionDetailsPage, submissionDetails()).get + implicit val request: DataRequest[_] = dataRequest(ua) + guard.check mustBe true + } + + "return true when amendment is Y and IRMarks match (even if status is not SUBMITTED)" in { + val details = submissionDetails(status = "PENDING", amendment = Some("Y")) + val ua = emptyUserAnswers().set(SubmissionDetailsPage, details).get + implicit val request: DataRequest[_] = dataRequest(ua) + guard.check mustBe true + } + + "return false when status is not SUBMITTED and amendment is not Y" in { + val details = submissionDetails(status = "PENDING", amendment = Some("N")) + val ua = emptyUserAnswers().set(SubmissionDetailsPage, details).get + implicit val request: DataRequest[_] = dataRequest(ua) + guard.check mustBe false + } + + "return false when status is not SUBMITTED and amendment is None" in { + val details = submissionDetails(status = "ACCEPTED", amendment = None) + val ua = emptyUserAnswers().set(SubmissionDetailsPage, details).get + implicit val request: DataRequest[_] = dataRequest(ua) + guard.check mustBe false + } + + "return false when irMark is empty" in { + val details = submissionDetails(irMark = "", hmrcMarkGgis = Some("")) + val ua = emptyUserAnswers().set(SubmissionDetailsPage, details).get + implicit val request: DataRequest[_] = dataRequest(ua) + guard.check mustBe false + } + + "return false when hmrcMarkGgis is None" in { + val details = submissionDetails(hmrcMarkGgis = None) + val ua = emptyUserAnswers().set(SubmissionDetailsPage, details).get + implicit val request: DataRequest[_] = dataRequest(ua) + guard.check mustBe false + } + + "return false when hmrcMarkGgis does not match irMark" in { + val details = submissionDetails(hmrcMarkGgis = Some("differentMark")) + val ua = emptyUserAnswers().set(SubmissionDetailsPage, details).get + implicit val request: DataRequest[_] = dataRequest(ua) + guard.check mustBe false + } + + "return false when hmrcMarkGgis is empty string" in { + val details = submissionDetails(hmrcMarkGgis = Some("")) + val ua = emptyUserAnswers().set(SubmissionDetailsPage, details).get + implicit val request: DataRequest[_] = dataRequest(ua) + guard.check mustBe false + } + } +} diff --git a/test/services/submission/SubmissionServiceSpec.scala b/test/services/submission/SubmissionServiceSpec.scala index 0baa728b..be698ab7 100644 --- a/test/services/submission/SubmissionServiceSpec.scala +++ b/test/services/submission/SubmissionServiceSpec.scala @@ -22,7 +22,7 @@ import connectors.ConstructionIndustrySchemeConnector import models.ReturnType.{MonthlyNilReturn, MonthlyStandardReturn} import models.UserAnswers import models.agent.AgentClientData -import models.monthlyreturns.{CisTaxpayer, InactivityRequest} +import models.monthlyreturns.{CisTaxpayer, GetAllMonthlyReturnDetailsResponse, InactivityRequest, MonthlyReturn} import models.requests.{DataRequest, SendSuccessEmailRequest} import models.submission.* import org.mockito.ArgumentCaptor @@ -249,6 +249,8 @@ class SubmissionServiceSpec extends SpecBase with TryValues { when(connector.submitToChris(eqTo("sub-123"), any[ChrisSubmissionRequest])(any[HeaderCarrier])) .thenReturn(Future.successful(beResp)) + stubRetrieveMonthlyReturnForEditDetails(connector) + val out = service.submitToChrisAndPersist("sub-123", uaWithInactivityYes, false).futureValue out mustBe beResp @@ -298,6 +300,8 @@ class SubmissionServiceSpec extends SpecBase with TryValues { when(connector.getCisTaxpayer()(any[HeaderCarrier])) .thenReturn(Future.successful(taxpayer)) + stubRetrieveMonthlyReturnForEditDetails(connector) + val beRespWithEndpoint = ChrisSubmissionResponse( submissionId = "sub-123", status = "SUBMITTED", @@ -336,6 +340,110 @@ class SubmissionServiceSpec extends SpecBase with TryValues { saved.get(LastMessageDatePage) mustBe None } + "persist amendment flag as Some(Y) when monthly return has amendment Y" in { + val connector: ConstructionIndustrySchemeConnector = mock(classOf[ConstructionIndustrySchemeConnector]) + val sessionRepository: SessionRepository = mock(classOf[SessionRepository]) + val appConfig: FrontendAppConfig = new FrontendAppConfig( + Configuration("submission-poll-timeout-seconds" -> "60") + ) + val chrisRequestBuilder = mock(classOf[ChrisSubmissionRequestBuilder]) + val service = new SubmissionService(connector, appConfig, sessionRepository, chrisRequestBuilder) + + when(connector.getCisTaxpayer()(any[HeaderCarrier])) + .thenReturn(Future.successful(taxpayer)) + + val builtCsr = mock(classOf[ChrisSubmissionRequest]) + when(chrisRequestBuilder.build(any[UserAnswers], any[CisTaxpayer], eqTo(false))(any[HeaderCarrier])) + .thenReturn(Future.successful(builtCsr)) + when(connector.submitToChris(eqTo("sub-123"), any[ChrisSubmissionRequest])(any[HeaderCarrier])) + .thenReturn(Future.successful(mkChrisResp())) + + val amendmentResponse = GetAllMonthlyReturnDetailsResponse( + scheme = Seq.empty, + monthlyReturn = Seq(MonthlyReturn(monthlyReturnId = 1, taxYear = 2025, taxMonth = 10, amendment = Some("Y"))), + subcontractors = Seq.empty, + monthlyReturnItems = Seq.empty, + submission = Seq.empty + ) + when(connector.retrieveMonthlyReturnForEditDetails(any[String], any[Int], any[Int])(any[HeaderCarrier])) + .thenReturn(Future.successful(amendmentResponse)) + + val uaCaptor: ArgumentCaptor[UserAnswers] = ArgumentCaptor.forClass(classOf[UserAnswers]) + when(sessionRepository.set(uaCaptor.capture())).thenReturn(Future.successful(true)) + + service.submitToChrisAndPersist("sub-123", uaWithInactivityYes, false).futureValue + + val saved = uaCaptor.getValue + saved.get(SubmissionDetailsPage).value.amendment mustBe Some("Y") + } + + "persist amendment flag as None when monthly return list is empty" in { + val connector: ConstructionIndustrySchemeConnector = mock(classOf[ConstructionIndustrySchemeConnector]) + val sessionRepository: SessionRepository = mock(classOf[SessionRepository]) + val appConfig: FrontendAppConfig = new FrontendAppConfig( + Configuration("submission-poll-timeout-seconds" -> "60") + ) + val chrisRequestBuilder = mock(classOf[ChrisSubmissionRequestBuilder]) + val service = new SubmissionService(connector, appConfig, sessionRepository, chrisRequestBuilder) + + when(connector.getCisTaxpayer()(any[HeaderCarrier])) + .thenReturn(Future.successful(taxpayer)) + + val builtCsr = mock(classOf[ChrisSubmissionRequest]) + when(chrisRequestBuilder.build(any[UserAnswers], any[CisTaxpayer], eqTo(false))(any[HeaderCarrier])) + .thenReturn(Future.successful(builtCsr)) + when(connector.submitToChris(eqTo("sub-123"), any[ChrisSubmissionRequest])(any[HeaderCarrier])) + .thenReturn(Future.successful(mkChrisResp())) + + val emptyResponse = GetAllMonthlyReturnDetailsResponse( + scheme = Seq.empty, + monthlyReturn = Seq.empty, + subcontractors = Seq.empty, + monthlyReturnItems = Seq.empty, + submission = Seq.empty + ) + when(connector.retrieveMonthlyReturnForEditDetails(any[String], any[Int], any[Int])(any[HeaderCarrier])) + .thenReturn(Future.successful(emptyResponse)) + + val uaCaptor: ArgumentCaptor[UserAnswers] = ArgumentCaptor.forClass(classOf[UserAnswers]) + when(sessionRepository.set(uaCaptor.capture())).thenReturn(Future.successful(true)) + + service.submitToChrisAndPersist("sub-123", uaWithInactivityYes, false).futureValue + + val saved = uaCaptor.getValue + saved.get(SubmissionDetailsPage).value.amendment mustBe None + } + + "default amendment flag to None when retrieveMonthlyReturnForEditDetails fails" in { + val connector: ConstructionIndustrySchemeConnector = mock(classOf[ConstructionIndustrySchemeConnector]) + val sessionRepository: SessionRepository = mock(classOf[SessionRepository]) + val appConfig: FrontendAppConfig = new FrontendAppConfig( + Configuration("submission-poll-timeout-seconds" -> "60") + ) + val chrisRequestBuilder = mock(classOf[ChrisSubmissionRequestBuilder]) + val service = new SubmissionService(connector, appConfig, sessionRepository, chrisRequestBuilder) + + when(connector.getCisTaxpayer()(any[HeaderCarrier])) + .thenReturn(Future.successful(taxpayer)) + + val builtCsr = mock(classOf[ChrisSubmissionRequest]) + when(chrisRequestBuilder.build(any[UserAnswers], any[CisTaxpayer], eqTo(false))(any[HeaderCarrier])) + .thenReturn(Future.successful(builtCsr)) + when(connector.submitToChris(eqTo("sub-123"), any[ChrisSubmissionRequest])(any[HeaderCarrier])) + .thenReturn(Future.successful(mkChrisResp())) + + when(connector.retrieveMonthlyReturnForEditDetails(any[String], any[Int], any[Int])(any[HeaderCarrier])) + .thenReturn(Future.failed(new RuntimeException("BE unavailable"))) + + val uaCaptor: ArgumentCaptor[UserAnswers] = ArgumentCaptor.forClass(classOf[UserAnswers]) + when(sessionRepository.set(uaCaptor.capture())).thenReturn(Future.successful(true)) + + service.submitToChrisAndPersist("sub-123", uaWithInactivityYes, false).futureValue + + val saved = uaCaptor.getValue + saved.get(SubmissionDetailsPage).value.amendment mustBe None + } + "fails fast and does not persist if updating UserAnswers fails" in { val connector: ConstructionIndustrySchemeConnector = mock(classOf[ConstructionIndustrySchemeConnector]) val sessionRepository: SessionRepository = mock(classOf[SessionRepository]) @@ -359,6 +467,8 @@ class SubmissionServiceSpec extends SpecBase with TryValues { when(connector.submitToChris(eqTo("sub-123"), any[ChrisSubmissionRequest])(any[HeaderCarrier])) .thenReturn(Future.successful(beResp)) + stubRetrieveMonthlyReturnForEditDetails(connector) + val ua = uaWithInactivityYes val uaSpy = spy(ua) @@ -403,6 +513,8 @@ class SubmissionServiceSpec extends SpecBase with TryValues { when(connector.submitToChris(eqTo("sub-123"), any[ChrisSubmissionRequest])(any[HeaderCarrier])) .thenReturn(Future.successful(beResp)) + stubRetrieveMonthlyReturnForEditDetails(connector) + val uaWithAgentClientData = uaWithInactivityYes.set(AgentClientDataPage, agentDate).success.value val out = service.submitToChrisAndPersist("sub-123", uaWithAgentClientData, true).futureValue out mustBe beResp @@ -1159,6 +1271,8 @@ class SubmissionServiceSpec extends SpecBase with TryValues { ) .thenReturn(Future.unit) + when(sessionRepository.get(any[String])).thenReturn(Future.successful(Some(ua))) + val savedCaptor: ArgumentCaptor[UserAnswers] = ArgumentCaptor.forClass(classOf[UserAnswers]) when(sessionRepository.set(savedCaptor.capture())) .thenReturn(Future.successful(true)) @@ -1304,6 +1418,7 @@ class SubmissionServiceSpec extends SpecBase with TryValues { ) ).thenReturn(Future.unit) + when(sessionRepository.get(any[String])).thenReturn(Future.successful(Some(ua))) when(sessionRepository.set(any[UserAnswers])) .thenReturn(Future.successful(true)) @@ -1353,6 +1468,7 @@ class SubmissionServiceSpec extends SpecBase with TryValues { .success .value + when(sessionRepository.get(any[String])).thenReturn(Future.successful(Some(ua))) when(sessionRepository.set(any[UserAnswers])) .thenReturn(Future.successful(true)) @@ -1404,6 +1520,7 @@ class SubmissionServiceSpec extends SpecBase with TryValues { ) ).thenReturn(Future.unit) + when(sessionRepository.get(any[String])).thenReturn(Future.successful(Some(ua))) when(sessionRepository.set(any[UserAnswers])) .thenReturn(Future.successful(true)) @@ -1559,6 +1676,18 @@ class SubmissionServiceSpec extends SpecBase with TryValues { private lazy val agentDate: AgentClientData = AgentClientData("CLIENT-123", "taxOfficeNumber", "taxOfficeReference", Some("PAL 355 Scheme")) + private val emptyMonthlyReturnDetailsResponse = GetAllMonthlyReturnDetailsResponse( + scheme = Seq.empty, + monthlyReturn = Seq(MonthlyReturn(monthlyReturnId = 1, taxYear = 2025, taxMonth = 10, amendment = Some("N"))), + subcontractors = Seq.empty, + monthlyReturnItems = Seq.empty, + submission = Seq.empty + ) + + private def stubRetrieveMonthlyReturnForEditDetails(connector: ConstructionIndustrySchemeConnector): Unit = + when(connector.retrieveMonthlyReturnForEditDetails(any[String], any[Int], any[Int])(any[HeaderCarrier])) + .thenReturn(Future.successful(emptyMonthlyReturnDetailsResponse)) + private def mkChrisResp( status: String = "SUBMITTED", irmark: String = "Dj5TVJDyRYCn9zta5EdySeY4fyA=", diff --git a/test/views/amend/ConfirmCancelAmendmentYesNoViewSpec.scala b/test/views/amend/ConfirmCancelAmendmentYesNoViewSpec.scala index 876b167e..a797813a 100644 --- a/test/views/amend/ConfirmCancelAmendmentYesNoViewSpec.scala +++ b/test/views/amend/ConfirmCancelAmendmentYesNoViewSpec.scala @@ -1,3 +1,19 @@ +/* + * 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 views.amend import base.SpecBase