diff --git a/app/config/ConfigDecorator.scala b/app/config/ConfigDecorator.scala index 723831851..46ce33550 100644 --- a/app/config/ConfigDecorator.scala +++ b/app/config/ConfigDecorator.scala @@ -56,6 +56,7 @@ class ConfigDecorator @Inject() ( private lazy val formFrontendService = servicesConfig.baseUrl("dfs-digital-forms-frontend") private lazy val taxCalcFrontendService = servicesConfig.baseUrl("taxcalc-frontend") private lazy val taxCalcFrontendExternal = getExternalUrl("taxcalc-frontend.host").getOrElse("") + private lazy val leppFrontendService = servicesConfig.baseUrl("low-earners-pensions-payment-frontend") lazy val businessTaxAccountService: String = servicesConfig.baseUrl("business-tax-account") @@ -267,6 +268,8 @@ class ConfigDecorator @Inject() ( servicesConfig.getInt("feature.preferences-frontend.timeoutInSec") lazy val enrolmentStoreProxyTimeoutInMilliseconds: Int = servicesConfig.getInt("microservice.services.enrolment-store-proxy.timeoutInMilliseconds") + lazy val leppTimeoutInMilliseconds: Int = + servicesConfig.getInt("microservice.services.low-earners-pensions-payment.timeoutInMilliseconds") lazy val ptaNinoSaveUrl: String = saveYourNationalInsuranceNumberHost + "/save-your-national-insurance-number" lazy val tellUsYourChildIsStayingInFullTimeEducation = "https://www.gov.uk/child-benefit-16-19" @@ -301,6 +304,12 @@ class ConfigDecorator @Inject() ( lazy val mtdGuidanceUrl: String = runModeConfiguration.get[String]("external-url.mtd-guidance.url") + lazy val leppStartUrl: String = + s"$leppFrontendService/accept-your-low-earners-pension-payment/start" + + lazy val leppPaymentsUrl: String = + s"$leppFrontendService/accept-your-low-earners-pension-payment/payments" + lazy val addressChangeBannerTextEn: String = runModeConfiguration.get[String]("feature.address-change-error.banner.paragraph.en") lazy val addressChangeBannerTextCy: String = diff --git a/app/config/HmrcModule.scala b/app/config/HmrcModule.scala index 1157e9834..bad4a38b4 100644 --- a/app/config/HmrcModule.scala +++ b/app/config/HmrcModule.scala @@ -33,6 +33,8 @@ class HmrcModule extends Module { .to[CachingCitizenDetailsConnector], // do not disable caching. The address change relies on the cache bind[TaiConnector].qualifiedWith("default").to[DefaultTaiConnector], bind[TaiConnector].to[CachingTaiConnector], + bind[LeppConnector].qualifiedWith("default").to[DefaultLeppConnector], + bind[LeppConnector].to[CachingLeppConnector], bind[EnrolmentsConnector].qualifiedWith("default").to[DefaultEnrolmentsConnector], bind[EnrolmentsConnector].to[CachingEnrolmentsConnector], bind[Encrypter with Decrypter].toProvider[CryptoProvider], diff --git a/app/connectors/LeppConnector.scala b/app/connectors/LeppConnector.scala new file mode 100644 index 000000000..ce6254859 --- /dev/null +++ b/app/connectors/LeppConnector.scala @@ -0,0 +1,97 @@ +/* + * 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 connectors + +import cats.data.EitherT +import com.google.common.util.concurrent.RateLimiter +import com.google.inject.name.Named +import com.google.inject.{Inject, Singleton} +import config.ConfigDecorator +import models.LeppSummaryResponse +import play.api.Logging +import play.api.mvc.Request +import services.CacheService +import uk.gov.hmrc.http.HttpReads.Implicits.* +import uk.gov.hmrc.http.client.HttpClientV2 +import uk.gov.hmrc.http.{HeaderCarrier, HttpResponse, StringContextOps, UpstreamErrorResponse} +import uk.gov.hmrc.play.bootstrap.config.ServicesConfig +import util.{Limiters, Throttle} + +import java.util.UUID.randomUUID +import scala.concurrent.duration.DurationInt +import scala.concurrent.{ExecutionContext, Future} + +trait LeppConnector { + def getLeppSummary(implicit + hc: HeaderCarrier, + ec: ExecutionContext, + request: Request[?] + ): EitherT[Future, UpstreamErrorResponse, LeppSummaryResponse] +} + +@Singleton +class DefaultLeppConnector @Inject() ( + val httpClientV2: HttpClientV2, + servicesConfig: ServicesConfig, + httpClientResponse: HttpClientResponse, + configDecorator: ConfigDecorator, + limiters: Limiters +) extends LeppConnector + with Throttle + with Logging { + + override val rateLimiter: RateLimiter = limiters.rateLimiterForLeppSummary + private lazy val baseUrl: String = servicesConfig.baseUrl("low-earners-pensions-payment") + + override def getLeppSummary(implicit + hc: HeaderCarrier, + ec: ExecutionContext, + request: Request[?] + ): EitherT[Future, UpstreamErrorResponse, LeppSummaryResponse] = { + val url = s"$baseUrl/low-earners-pensions-payment/get-lepp-summary" + + implicit val leppHeaderCarrier: HeaderCarrier = hc.withExtraHeaders( + "correlationId" -> randomUUID.toString + ) + + val response: Future[Either[UpstreamErrorResponse, HttpResponse]] = + withThrottle { + httpClientV2 + .get(url"$url")(leppHeaderCarrier) + .transform(_.withRequestTimeout(configDecorator.leppTimeoutInMilliseconds.milliseconds)) + .execute[Either[UpstreamErrorResponse, HttpResponse]](readEitherOf(readRaw), ec) + } + + httpClientResponse.read(response).map(_.json.as[LeppSummaryResponse]) + } +} + +@Singleton +class CachingLeppConnector @Inject() ( + @Named("default") underlying: LeppConnector, + cacheService: CacheService +) extends LeppConnector { + + override def getLeppSummary(implicit + hc: HeaderCarrier, + ec: ExecutionContext, + request: Request[?] + ): EitherT[Future, UpstreamErrorResponse, LeppSummaryResponse] = + cacheService.cache("leppSummary") { + underlying.getLeppSummary + } +} diff --git a/app/models/LeppSummaryResponse.scala b/app/models/LeppSummaryResponse.scala new file mode 100644 index 000000000..521b6f433 --- /dev/null +++ b/app/models/LeppSummaryResponse.scala @@ -0,0 +1,25 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package models + +import play.api.libs.json.{Json, OFormat} + +case class LeppSummaryResponse(status: String) + +object LeppSummaryResponse { + implicit val formats: OFormat[LeppSummaryResponse] = Json.format[LeppSummaryResponse] +} diff --git a/app/models/admin/FeatureFlags.scala b/app/models/admin/FeatureFlags.scala index 13ebcf9f0..67680e57d 100644 --- a/app/models/admin/FeatureFlags.scala +++ b/app/models/admin/FeatureFlags.scala @@ -40,6 +40,7 @@ object AllFeatureFlags { MTDUserStatusToggle, GetMatchingFromCitizenDetailsToggle, ClaimMtdFromPtaToggle, + LowEarnersPensionsPaymentToggle, HomePageChangesBannerToggle, HomePagePersonalisationToggle, PtapActivityTabToggle @@ -207,6 +208,16 @@ case object ClaimMtdFromPtaToggle extends FeatureFlagName { Seq(Environment.Staging, Environment.Qa, Environment.Production) } +case object LowEarnersPensionsPaymentToggle extends FeatureFlagName { + override val name: String = "low-earners-pensions-payment-toggle" + + override val description: Option[String] = Some( + "Enable/disable Low earner's pension payment tile in Taxes and benefits" + ) + + override val defaultState: Boolean = false +} + case object HomePageChangesBannerToggle extends FeatureFlagName { override val name: String = "home-change-banner-toggle" diff --git a/app/services/HomePageServicesProvider.scala b/app/services/HomePageServicesProvider.scala index cc68022eb..fd87eb721 100644 --- a/app/services/HomePageServicesProvider.scala +++ b/app/services/HomePageServicesProvider.scala @@ -34,7 +34,8 @@ class HomePageServicesProvider @Inject() ( configDecorator: ConfigDecorator, featureFlagService: FeatureFlagService, fandFService: FandFService, - taiService: TaiService + taiService: TaiService, + leppService: LeppService )(implicit ec: ExecutionContext) extends CurrentTaxYear { @@ -65,6 +66,7 @@ class HomePageServicesProvider @Inject() ( selfAssessmentOther <- getOtherSelfAssessment(request.saUserType, isTrustedHelperUser) mtdOther <- getMtdOtherService(isTrustedHelperUser) childBenefit <- getChildBenefit(isTrustedHelperUser, isRedesign) + lepp <- getLepp(isTrustedHelperUser) annualTaxSummary <- getAnnualTaxSummaries(isTrustedHelperUser, isRedesign) marriageAllowance <- marriageAllowanceF(isRedesign) trustedHelper <- trustedHelperF(isRedesign) @@ -77,6 +79,7 @@ class HomePageServicesProvider @Inject() ( selfAssessmentOther, mtdOther, childBenefit, + lepp, annualTaxSummary ).flatten ++ marriageAllowance ++ trustedHelper ) @@ -307,6 +310,25 @@ class HomePageServicesProvider @Inject() ( } } + private def leppTile(linkUrl: String)(implicit messages: Messages): MyService = + MyService( + messages("label.lepp.title"), + Some(linkUrl), + gaAction = Some("Benefits"), + gaLabel = Some("Low earner's pension payment (LEPP)"), + id = Some("lepp"), + hintText = Some(messages("label.lepp.hint")) + ) + + private def getLepp( + isTrustedHelperUser: Boolean + )(implicit hc: HeaderCarrier, request: UserRequest[?], messages: Messages): Future[Option[MyService]] = + if (isTrustedHelperUser) { + Future.successful(None) + } else { + leppService.getLeppLink.map(_.map(leppTile)) + } + private def getAnnualTaxSummaries(isTrustedHelperUser: Boolean, isRedesign: Boolean)(implicit messages: Messages ): Future[Option[OtherService]] = diff --git a/app/services/LeppService.scala b/app/services/LeppService.scala new file mode 100644 index 000000000..788f34d44 --- /dev/null +++ b/app/services/LeppService.scala @@ -0,0 +1,54 @@ +/* + * Copyright 2026 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package services + +import com.google.inject.Inject +import config.ConfigDecorator +import connectors.LeppConnector +import models.admin.LowEarnersPensionsPaymentToggle +import play.api.Logging +import play.api.mvc.Request +import uk.gov.hmrc.http.HeaderCarrier +import uk.gov.hmrc.mongoFeatureToggles.services.FeatureFlagService + +import scala.concurrent.{ExecutionContext, Future} + +class LeppService @Inject() ( + leppConnector: LeppConnector, + featureFlagService: FeatureFlagService, + configDecorator: ConfigDecorator +)(implicit ec: ExecutionContext) + extends Logging { + + def getLeppLink(implicit hc: HeaderCarrier, request: Request[?]): Future[Option[String]] = + featureFlagService.get(LowEarnersPensionsPaymentToggle).flatMap { toggle => + if (toggle.isEnabled) { + leppConnector.getLeppSummary + .fold(_ => Option.empty[String], response => linkForStatus(response.status)) + .recover { case _ => None } + } else { + Future.successful(None) + } + } + + private def linkForStatus(status: String): Option[String] = + status match { + case "PAYMENTS_AVAILABLE" => Some(configDecorator.leppStartUrl) + case "NO_ACTIONS" => Some(configDecorator.leppPaymentsUrl) + case _ => None + } +} diff --git a/app/util/RateLimiter.scala b/app/util/RateLimiter.scala index 99ccc30c3..9bff4bbae 100644 --- a/app/util/RateLimiter.scala +++ b/app/util/RateLimiter.scala @@ -27,7 +27,11 @@ class Limiters @Inject() (configuration: Configuration) { private lazy val maxTpsForGetClientStatus = configuration .getOptional[Double]("feature.agent-client-relationships.maxTps") .getOrElse(100.0) + private lazy val maxTpsForLeppSummary = configuration + .getOptional[Double]("feature.low-earners-pensions-payment.maxTps") + .getOrElse(100.0) val rateLimiterForGetClientStatus: RateLimiter = RateLimiter.create(maxTpsForGetClientStatus) + val rateLimiterForLeppSummary: RateLimiter = RateLimiter.create(maxTpsForLeppSummary) } case object RateLimitedException extends RuntimeException diff --git a/conf/application.conf b/conf/application.conf index f07d2fbd1..dfdeb9332 100644 --- a/conf/application.conf +++ b/conf/application.conf @@ -105,6 +105,15 @@ microservice { port = 9331 timeoutInMilliseconds = 500 } + low-earners-pensions-payment-frontend { + host = localhost + port = 7503 + } + low-earners-pensions-payment { + host = localhost + port = 7504 + timeoutInMilliseconds = 500 + } pertax-frontend { host = localhost port = 9232 diff --git a/conf/messages b/conf/messages index acfd2d941..537b40a06 100644 --- a/conf/messages +++ b/conf/messages @@ -495,6 +495,8 @@ label.your_self_assessment=Your Self Assessment label.online_returns_deadline=The deadline for online returns is 31 January {0}. label.mtd_for_it=Making Tax Digital for Income Tax label.mtd_for_it_sa=Making Tax Digital for Income Tax +label.lepp.title=Low earner''s pension payment +label.lepp.hint=View and accept your low earner''s pension payment. label.send_updates_hmrc_compatible_software=Send updates using HMRC compatible software. label.send_updates_sole_traders=For sole traders and landlords that send quarterly updates using software. label.view_manage_your_mtd_for_it=View and manage Making Tax Digital for Income Tax diff --git a/conf/messages.cy b/conf/messages.cy index 9621b0141..b6bfd544b 100644 --- a/conf/messages.cy +++ b/conf/messages.cy @@ -823,6 +823,8 @@ label.mtd_for_it=Troi Treth yn Ddigidol ar gyfer Treth Incwm label.mtd_for_itsa=Hunanasesiad a Throi Treth yn Ddigidol ar gyfer Treth Incwm label.view_and_manage_your_income_tax_obligations_and_payments=Bwrw golwg dros eich rhwymedigaethau a’ch taliadau Treth Incwm, a’u rheoli. label.mtd_for_it_sa=Troi Treth yn Ddigidol ar gyfer Treth Incwm +label.lepp.title=Taliad pensiwn i’r sawl sy’n ennill incwm isel +label.lepp.hint=Bwrw golwg dros a derbyn eich taliad pensiwn i’r sawl sy’n ennill incwm isel. label.send_updates_sole_traders=Ar gyfer unig fasnachwyr a landlordiaid sy’n anfon diweddariadau chwarterol gan ddefnyddio meddalwedd. label.view_manage_your_mtd_for_it=Bwrw golwg dros y cynllun Troi Treth yn Ddigidol ar gyfer Treth Incwm a’i reoli label.view_manage_your_mtd_itsa=Bwrw golwg dros eich cynllun Troi Treth yn Ddigidol ar gyfer Treth Incwm a’i reoli, neu gael at eich Ffurflenni Treth Hunanasesiad. diff --git a/it/test/address/RLSInterruptPageSpec.scala b/it/test/address/RLSInterruptPageSpec.scala index 44da90dfd..5f17b94a4 100644 --- a/it/test/address/RLSInterruptPageSpec.scala +++ b/it/test/address/RLSInterruptPageSpec.scala @@ -20,6 +20,7 @@ import cats.data.EitherT import com.github.tomakehurst.wiremock.client.WireMock.{get, ok, status as _, urlEqualTo} import config.{ApplicationStartUp, CryptoProvider} import connectors._ +import connectors.{DefaultLeppConnector, LeppConnector} import models.admin.* import org.mockito.ArgumentMatchers import org.mockito.Mockito.when @@ -50,7 +51,8 @@ class RLSInterruptPageSpec extends IntegrationSpec { inject.bind[AgentClientAuthorisationConnector].to[DefaultAgentClientAuthorisationConnector], inject.bind[EnrolmentsConnector].to[DefaultEnrolmentsConnector], inject.bind[Encrypter with Decrypter].toProvider[CryptoProvider], - inject.bind[TaiConnector].to[DefaultTaiConnector] + inject.bind[TaiConnector].to[DefaultTaiConnector], + inject.bind[LeppConnector].to[DefaultLeppConnector] ) .build() diff --git a/it/test/testUtils/IntegrationSpec.scala b/it/test/testUtils/IntegrationSpec.scala index 542153ea2..e26fba84e 100644 --- a/it/test/testUtils/IntegrationSpec.scala +++ b/it/test/testUtils/IntegrationSpec.scala @@ -369,6 +369,7 @@ trait IntegrationSpec "microservice.services.breathing-space-if-proxy.port" -> server.port(), "microservice.services.taxcalc-frontend.port" -> server.port(), "microservice.services.fandf.port" -> server.port(), + "microservice.services.low-earners-pensions-payment.port" -> server.port(), "sca-wrapper.services.single-customer-account-wrapper-data.url" -> s"http://localhost:${server.port()}" ) diff --git a/test/connectors/CachingLeppConnectorSpec.scala b/test/connectors/CachingLeppConnectorSpec.scala new file mode 100644 index 000000000..1f03b4044 --- /dev/null +++ b/test/connectors/CachingLeppConnectorSpec.scala @@ -0,0 +1,61 @@ +/* + * 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 connectors + +import cats.data.EitherT +import models.LeppSummaryResponse +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito.{reset, when} +import play.api.mvc.AnyContentAsEmpty +import play.api.test.FakeRequest +import services.CacheService +import testUtils.{BaseSpec, WireMockHelper} +import uk.gov.hmrc.http.{HeaderCarrier, UpstreamErrorResponse} + +import scala.concurrent.{ExecutionContext, Future} + +class CachingLeppConnectorSpec extends ConnectorSpec with BaseSpec with WireMockHelper { + + private val injectedCacheService: CacheService = app.injector.instanceOf[CacheService] + private val mockLeppConnector: LeppConnector = mock[LeppConnector] + + override implicit val hc: HeaderCarrier = HeaderCarrier() + override implicit lazy val ec: ExecutionContext = scala.concurrent.ExecutionContext.global + + private def connector: CachingLeppConnector = + new CachingLeppConnector(mockLeppConnector, injectedCacheService) + + implicit val request: FakeRequest[AnyContentAsEmpty.type] = FakeRequest() + + override def beforeEach(): Unit = { + super.beforeEach() + reset(mockLeppConnector) + } + + "CachingLeppConnector.getLeppSummary" must { + + "fetch from service cache" in { + val response = LeppSummaryResponse("PAYMENTS_AVAILABLE") + when(mockLeppConnector.getLeppSummary(any(), any(), any())) + .thenReturn(EitherT.rightT[Future, UpstreamErrorResponse](response)) + + val result = connector.getLeppSummary.value.futureValue + + result mustBe Right(response) + } + } +} diff --git a/test/connectors/DefaultLeppConnectorSpec.scala b/test/connectors/DefaultLeppConnectorSpec.scala new file mode 100644 index 000000000..29cfed0cc --- /dev/null +++ b/test/connectors/DefaultLeppConnectorSpec.scala @@ -0,0 +1,70 @@ +/* + * 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 connectors + +import com.github.tomakehurst.wiremock.client.WireMock.{getRequestedFor, matching, urlEqualTo} +import models.LeppSummaryResponse +import org.scalatest.concurrent.IntegrationPatience +import play.api.Application +import play.api.mvc.AnyContentAsEmpty +import play.api.test.FakeRequest +import testUtils.WireMockHelper +import uk.gov.hmrc.http.UpstreamErrorResponse + +class DefaultLeppConnectorSpec extends ConnectorSpec with WireMockHelper with IntegrationPatience { + + override implicit lazy val app: Application = + app( + Map( + "microservice.services.low-earners-pensions-payment.port" -> server.port(), + "microservice.services.low-earners-pensions-payment.timeoutInMilliseconds" -> 1000, + "feature.low-earners-pensions-payment.maxTps" -> 1000 + ) + ) + + private def connector: DefaultLeppConnector = + app.injector.instanceOf[DefaultLeppConnector] + + implicit val request: FakeRequest[AnyContentAsEmpty.type] = FakeRequest() + + private val url = "/low-earners-pensions-payment/get-lepp-summary" + + "DefaultLeppConnector.getLeppSummary" must { + + "return the LEPP summary response and send a correlationId header" in { + stubGet(url, OK, Some("""{"status":"PAYMENTS_AVAILABLE","data":{}}""")) + + val result = connector.getLeppSummary.value.futureValue + + result mustBe Right(LeppSummaryResponse("PAYMENTS_AVAILABLE")) + server.verify( + getRequestedFor(urlEqualTo(url)).withHeader( + "correlationId", + matching("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}") + ) + ) + } + + "return Left when the LEPP backend returns an error" in { + stubGet(url, INTERNAL_SERVER_ERROR, None) + + val result = connector.getLeppSummary.value.futureValue + + result mustBe a[Left[UpstreamErrorResponse, _]] + } + } +} diff --git a/test/controllers/HomeControllerSpec.scala b/test/controllers/HomeControllerSpec.scala index 038fd2485..34c15862e 100644 --- a/test/controllers/HomeControllerSpec.scala +++ b/test/controllers/HomeControllerSpec.scala @@ -25,7 +25,7 @@ import models.BreathingSpaceIndicatorResponse.WithinPeriod import models.admin.{GetPersonFromCitizenDetailsToggle, HomePagePersonalisationToggle, PtapActivityTabToggle, ShowPlannedOutageBannerToggle} import models.{BreathingSpaceIndicatorResponse, HomePageServices, MyService, OtherService} import org.jsoup.Jsoup -import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.{any, anyBoolean} import org.mockito.Mockito.{reset, verify, when} import play.api.Application import play.api.i18n.{Lang, Messages, MessagesImpl} @@ -144,7 +144,7 @@ class HomeControllerSpec extends BaseSpec with WireMockHelper with CitizenDetail when(mockConfigDecorator.ptapHomepageNinoRolloutLastNumericDigits) .thenReturn(Seq(0, 1, 2, 3, 4, 5, 6, 7, 8, 9)) - when(mockHomePageServicesProvider.getHomePageServices(any())(any(), any(), any())) + when(mockHomePageServicesProvider.getHomePageServices(anyBoolean())(any(), any(), any())) .thenReturn(Future.successful(HomePageServices(Seq.empty))) when(mockCitizenDetailsService.personDetails(any(), any())(any(), any(), any())) @@ -667,7 +667,7 @@ class HomeControllerSpec extends BaseSpec with WireMockHelper with CitizenDetail hintText = Some("Child Benefit hint") ) - when(mockHomePageServicesProvider.getHomePageServices(any())(any(), any(), any())) + when(mockHomePageServicesProvider.getHomePageServices(anyBoolean())(any(), any(), any())) .thenReturn(Future.successful(HomePageServices(Seq(payeService, childBenefitService)))) val appLocal = appBuilder.build() diff --git a/test/services/HomePageServicesProviderSpec.scala b/test/services/HomePageServicesProviderSpec.scala index 6ca0e9856..b20060b5f 100644 --- a/test/services/HomePageServicesProviderSpec.scala +++ b/test/services/HomePageServicesProviderSpec.scala @@ -42,13 +42,15 @@ class HomePageServicesProviderSpec extends BaseSpec { private val mockFeatureFlagService: FeatureFlagService = mock[FeatureFlagService] private val mockFandFService: FandFService = mock[FandFService] private val mockTaiService: TaiService = mock[TaiService] + private val mockLeppService: LeppService = mock[LeppService] private lazy val service = new HomePageServicesProvider( mockConfigDecorator, mockFeatureFlagService, mockFandFService, - mockTaiService + mockTaiService, + mockLeppService ) implicit lazy val messages: Messages = MessagesImpl(Lang("en"), messagesApi) @@ -77,6 +79,7 @@ class HomePageServicesProviderSpec extends BaseSpec { reset(mockFeatureFlagService) reset(mockFandFService) reset(mockTaiService) + reset(mockLeppService) when(mockFeatureFlagService.get(eqTo(ShowTaxCalcTileToggle))) .thenReturn(Future.successful(FeatureFlag(ShowTaxCalcTileToggle, isEnabled = false))) @@ -87,6 +90,9 @@ class HomePageServicesProviderSpec extends BaseSpec { when(mockFandFService.isAnyFandFRelationships(any())(any())) .thenReturn(Future.successful(false)) + when(mockLeppService.getLeppLink(any(), any())) + .thenReturn(Future.successful(None)) + when(mockConfigDecorator.taxCalcHomePageUrl).thenReturn("taxcalc/") when(mockConfigDecorator.taxCalcYearsToShow).thenReturn(4) when(mockConfigDecorator.ssoToActivateSaEnrolmentPinUrl).thenReturn("activate-sa-url") diff --git a/test/services/LeppServiceSpec.scala b/test/services/LeppServiceSpec.scala new file mode 100644 index 000000000..eed7fff0c --- /dev/null +++ b/test/services/LeppServiceSpec.scala @@ -0,0 +1,126 @@ +/* + * 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 cats.data.EitherT +import config.ConfigDecorator +import connectors.LeppConnector +import models.LeppSummaryResponse +import models.admin.LowEarnersPensionsPaymentToggle +import org.mockito.ArgumentMatchers +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito.{reset, times, verify, when} +import play.api.http.Status.INTERNAL_SERVER_ERROR +import play.api.mvc.AnyContentAsEmpty +import play.api.test.FakeRequest +import testUtils.BaseSpec +import uk.gov.hmrc.http.UpstreamErrorResponse +import uk.gov.hmrc.mongoFeatureToggles.model.FeatureFlag + +import scala.concurrent.Future + +class LeppServiceSpec extends BaseSpec { + + private val mockLeppConnector: LeppConnector = mock[LeppConnector] + private val mockConfigDecorator: ConfigDecorator = mock[ConfigDecorator] + private val startUrl = + "https://www.tax.service.gov.uk/accept-your-low-earners-pension-payment/start" + private val paymentsUrl = + "https://www.tax.service.gov.uk/accept-your-low-earners-pension-payment/payments" + private val sut: LeppService = + new LeppService(mockLeppConnector, mockFeatureFlagService, mockConfigDecorator) + + implicit val fakeRequest: FakeRequest[AnyContentAsEmpty.type] = FakeRequest() + + override def beforeEach(): Unit = { + super.beforeEach() + reset(mockLeppConnector) + reset(mockConfigDecorator) + + when(mockConfigDecorator.leppStartUrl).thenReturn(startUrl) + when(mockConfigDecorator.leppPaymentsUrl).thenReturn(paymentsUrl) + when(mockFeatureFlagService.get(ArgumentMatchers.eq(LowEarnersPensionsPaymentToggle))) + .thenReturn(Future.successful(FeatureFlag(LowEarnersPensionsPaymentToggle, isEnabled = true))) + } + + private def stubSummary(status: String): Unit = + when(mockLeppConnector.getLeppSummary(any(), any(), any())) + .thenReturn(EitherT.rightT[Future, UpstreamErrorResponse](LeppSummaryResponse(status))) + + "getLeppLink" must { + + "return None and not call LEPP when the toggle is disabled" in { + when(mockFeatureFlagService.get(ArgumentMatchers.eq(LowEarnersPensionsPaymentToggle))) + .thenReturn(Future.successful(FeatureFlag(LowEarnersPensionsPaymentToggle, isEnabled = false))) + + sut.getLeppLink.futureValue mustBe None + verify(mockLeppConnector, times(0)).getLeppSummary(any(), any(), any()) + } + + "return the start URL when payments are available" in { + stubSummary("PAYMENTS_AVAILABLE") + + sut.getLeppLink.futureValue mustBe Some(startUrl) + } + + "return the payments URL when no actions are available" in { + stubSummary("NO_ACTIONS") + + sut.getLeppLink.futureValue mustBe Some(paymentsUrl) + } + + "return None when the user is not eligible" in { + stubSummary("NOT_ELIGIBLE") + + sut.getLeppLink.futureValue mustBe None + } + + "return None for CHECK because the ticket does not define a tile action for it" in { + stubSummary("CHECK") + + sut.getLeppLink.futureValue mustBe None + } + + "return None for an unknown status" in { + stubSummary("UNKNOWN") + + sut.getLeppLink.futureValue mustBe None + } + + "return None when the LEPP backend returns an error" in { + when(mockLeppConnector.getLeppSummary(any(), any(), any())) + .thenReturn( + EitherT.leftT[Future, LeppSummaryResponse]( + UpstreamErrorResponse("server error", INTERNAL_SERVER_ERROR) + ) + ) + + sut.getLeppLink.futureValue mustBe None + } + + "return None when the LEPP backend does not respond" in { + when(mockLeppConnector.getLeppSummary(any(), any(), any())) + .thenReturn( + EitherT[Future, UpstreamErrorResponse, LeppSummaryResponse]( + Future.failed(new RuntimeException("No response")) + ) + ) + + sut.getLeppLink.futureValue mustBe None + } + } +}