diff --git a/support-config/src/main/resources/touchpoint.CODE.conf b/support-config/src/main/resources/touchpoint.CODE.conf index 63c1df6bb8..794d9e1c54 100644 --- a/support-config/src/main/resources/touchpoint.CODE.conf +++ b/support-config/src/main/resources/touchpoint.CODE.conf @@ -30,6 +30,9 @@ touchpoint.backend.environments { nvp-version = "124.0" url = "https://api-3t.sandbox.paypal.com/nvp" } + userBenefitsApi { + host="user-benefits.code.dev-guardianapis.com" + } zuora { api { url = "https://rest.apisandbox.zuora.com/v1" diff --git a/support-config/src/main/resources/touchpoint.PROD.conf b/support-config/src/main/resources/touchpoint.PROD.conf index a359b042a4..642fcd5483 100644 --- a/support-config/src/main/resources/touchpoint.PROD.conf +++ b/support-config/src/main/resources/touchpoint.PROD.conf @@ -30,6 +30,9 @@ touchpoint.backend.environments { nvp-version = "124.0" url="https://api-3t.paypal.com/nvp" } + userBenefitsApi { + host="user-benefits.guardianapis.com" + } zuora { api { url = "https://rest.zuora.com/v1" diff --git a/support-config/src/main/scala/com/gu/support/config/UserBenefitsApiConfig.scala b/support-config/src/main/scala/com/gu/support/config/UserBenefitsApiConfig.scala new file mode 100644 index 0000000000..ab5cde7403 --- /dev/null +++ b/support-config/src/main/scala/com/gu/support/config/UserBenefitsApiConfig.scala @@ -0,0 +1,18 @@ +package com.gu.support.config + +import com.typesafe.config.Config + +case class UserBenefitsApiConfig( + host: String, + apiKey: String, +) + +class UserBenefitsApiConfigProvider(config: Config, defaultStage: Stage) + extends TouchpointConfigProvider[UserBenefitsApiConfig](config, defaultStage) { + def fromConfig(config: Config): UserBenefitsApiConfig = { + UserBenefitsApiConfig( + config.getString("userBenefitsApi.host"), + config.getString("userBenefitsApi.apiKey"), + ) + } +} diff --git a/support-frontend/app/config/Configuration.scala b/support-frontend/app/config/Configuration.scala index ec7862b0a4..6cb3999c4f 100644 --- a/support-frontend/app/config/Configuration.scala +++ b/support-frontend/app/config/Configuration.scala @@ -28,6 +28,8 @@ class Configuration(config: TypesafeConfig) { lazy val paymentApiUrl = config.getString("paymentApi.url") + lazy val userBenefitsApiConfigProvider = new UserBenefitsApiConfigProvider(config, stage) + lazy val membersDataServiceApiUrl = config.getString("membersDataService.api.url") lazy val metricUrl = MetricUrl(config.getString("metric.url")) diff --git a/support-frontend/app/controllers/CreateSubscriptionController.scala b/support-frontend/app/controllers/CreateSubscriptionController.scala index 3cecc78dbc..5b0283e8d6 100644 --- a/support-frontend/app/controllers/CreateSubscriptionController.scala +++ b/support-frontend/app/controllers/CreateSubscriptionController.scala @@ -25,7 +25,16 @@ import play.api.libs.circe.Circe import play.api.mvc._ import services.AsyncAuthenticationService.IdentityIdAndEmail import services.stepfunctions.{CreateSupportWorkersRequest, SupportWorkersClient} -import services.{IdentityService, RecaptchaResponse, RecaptchaService, TestUserService, UserDetails} +import services.{ + IdentityService, + RecaptchaResponse, + RecaptchaService, + TestUserService, + UserBenefitsApiService, + UserBenefitsApiServiceProvider, + UserBenefitsResponse, + UserDetails, +} import utils.CheckoutValidationRules.{Invalid, Valid} import utils.{CheckoutValidationRules, NormalisedTelephoneNumber, PaperValidation} @@ -63,6 +72,7 @@ class CreateSubscriptionController( components: ControllerComponents, guardianDomain: GuardianDomain, paperRoundServiceProvider: PaperRoundServiceProvider, + userBenefitsApiServiceProvider: UserBenefitsApiServiceProvider, )(implicit val ec: ExecutionContext, system: ActorSystem) extends AbstractController(components) with Circe @@ -142,13 +152,60 @@ class CreateSubscriptionController( } } + private def validateBenefitsForAdLitePurchase( + response: UserBenefitsResponse, + ): EitherT[Future, CreateSubscriptionError, Unit] = { + if (response.benefits.contains("adFree") || response.benefits.contains("allowRejectAll")) { + // Not eligible + EitherT.leftT(RequestValidationError("guardian_ad_lite_purchase_not_allowed")) + } else { + // Eligible + EitherT.rightT(()) + } + } + + private def validateUserIsEligibleForPurchase( + request: Request[CreateSupportWorkersRequest], + userDetails: UserDetailsWithSignedInStatus, + ): EitherT[Future, CreateSubscriptionError, Unit] = { + request.body.product match { + case GuardianAdLite(_) => { + if (userDetails.isSignedIn) { + // If the user is signed in, we'll assume they're eligible as they shouldn't have got + // to this point of the journey. + EitherT.rightT(()) + } else { + for { + benefits <- userBenefitsApiServiceProvider + .forUser(testUsers.isTestUser(request)) + .getUserBenefits(userDetails.userDetails.identityId) + .leftMap(_ => ServerError("Something went wrong calling the user benefits API")) + _ <- validateBenefitsForAdLitePurchase(benefits) + } yield () + } + } + case _ => EitherT.rightT(()) + } + } + + case class UserDetailsWithSignedInStatus( + userDetails: UserDetails, + isSignedIn: Boolean, + ) + private def createSubscription(implicit settings: AllSettings, request: CreateRequest, ): EitherT[Future, CreateSubscriptionError, CreateSubscriptionResponse] = { val maybeLoggedInUserDetails = - request.user.map(authIdUser => UserDetails(authIdUser.id, authIdUser.primaryEmailAddress, "current")) + request.user.map(authIdUser => + UserDetailsWithSignedInStatus( + UserDetails(authIdUser.id, authIdUser.primaryEmailAddress, "current"), + isSignedIn = true, + ), + ) + logDetailedMessage("createSubscription") for { @@ -157,9 +214,13 @@ class CreateSubscriptionController( case Some(userDetails) => EitherT.pure[Future, CreateSubscriptionError](userDetails) case None => getOrCreateIdentityUser(request.body, request.headers.get("Referer")) + .map(userDetails => UserDetailsWithSignedInStatus(userDetails, isSignedIn = false)) .leftMap(mapIdentityErrorToCreateSubscriptionError) } - supportWorkersUser = buildSupportWorkersUser(userDetails, request.body, testUsers.isTestUser(request)) + + _ <- validateUserIsEligibleForPurchase(request, userDetails) + + supportWorkersUser = buildSupportWorkersUser(userDetails.userDetails, request.body, testUsers.isTestUser(request)) statusResponse <- client .createSubscription(request, supportWorkersUser, request.uuid) @@ -167,7 +228,7 @@ class CreateSubscriptionController( } yield CreateSubscriptionResponse( statusResponse.status, - userDetails.userType, + userDetails.userDetails.userType, statusResponse.trackingUri, statusResponse.failureReason, ) diff --git a/support-frontend/app/services/UserBenefitsApiService.scala b/support-frontend/app/services/UserBenefitsApiService.scala new file mode 100644 index 0000000000..5b62cbe654 --- /dev/null +++ b/support-frontend/app/services/UserBenefitsApiService.scala @@ -0,0 +1,57 @@ +package services + +import cats.data.EitherT +import cats.implicits._ +import com.gu.support.touchpoint.TouchpointService +import play.api.libs.json.{JsPath, Json, JsonValidationError, Reads} +import play.api.libs.ws.{WSClient, WSRequest, WSResponse} + +import scala.collection.Seq +import scala.concurrent.{ExecutionContext, Future} + +case class UserBenefitsResponse(benefits: List[String]) +object UserBenefitsResponse { + implicit val readsUserBenefitsResponse: Reads[UserBenefitsResponse] = Json.reads[UserBenefitsResponse] +} + +sealed trait UserBenefitsError { + def describeError: String +} + +object UserBenefitsError { + case class CallFailed(error: Throwable) extends UserBenefitsError { + override def describeError: String = s"Call failed with error: ${error.getMessage}" + } + + case class GotErrorResponse(response: WSResponse) extends UserBenefitsError { + override def describeError: String = s"Got error response: ${response.status} ${response.body}" + } + + case class DecodeFailed(decodeErrors: Seq[(JsPath, Seq[JsonValidationError])]) extends UserBenefitsError { + override def describeError: String = s"Failed to decode response: ${decodeErrors.mkString(",")}" + } +} + +class UserBenefitsApiService(host: String, apiKey: String)(implicit wsClient: WSClient) extends TouchpointService { + def getUserBenefits( + identityId: String, + )(implicit ec: ExecutionContext): EitherT[Future, UserBenefitsError, UserBenefitsResponse] = { + request(s"benefits/$identityId") + .get() + .attemptT + .leftMap(UserBenefitsError.CallFailed) + .subflatMap(resp => + if (resp.status >= 300) { + Left(UserBenefitsError.GotErrorResponse(resp)) + } else { + resp.json.validate[UserBenefitsResponse].asEither.leftMap(UserBenefitsError.DecodeFailed) + }, + ) + } + + private def request(path: String): WSRequest = { + wsClient + .url(s"https://$host/$path") + .withHttpHeaders("x-api-key" -> apiKey) + } +} diff --git a/support-frontend/app/services/UserBenefitsApiServiceProvider.scala b/support-frontend/app/services/UserBenefitsApiServiceProvider.scala new file mode 100644 index 0000000000..242d0471c7 --- /dev/null +++ b/support-frontend/app/services/UserBenefitsApiServiceProvider.scala @@ -0,0 +1,15 @@ +package services + +import com.gu.support.config.{UserBenefitsApiConfig, UserBenefitsApiConfigProvider} +import com.gu.support.touchpoint.TouchpointServiceProvider +import play.api.libs.ws.WSClient + +import scala.concurrent.ExecutionContext + +class UserBenefitsApiServiceProvider(configProvider: UserBenefitsApiConfigProvider)(implicit + executionContext: ExecutionContext, + wsClient: WSClient, +) extends TouchpointServiceProvider[UserBenefitsApiService, UserBenefitsApiConfig](configProvider) { + override protected def createService(config: UserBenefitsApiConfig) = + new UserBenefitsApiService(config.host, config.apiKey) +} diff --git a/support-frontend/app/wiring/Controllers.scala b/support-frontend/app/wiring/Controllers.scala index 64e91a3376..b363f7fa95 100644 --- a/support-frontend/app/wiring/Controllers.scala +++ b/support-frontend/app/wiring/Controllers.scala @@ -169,6 +169,7 @@ trait Controllers { controllerComponents, appConfig.guardianDomain, paperRoundServiceProvider, + userBenefitsApiServiceProvider, ) lazy val supportWorkersStatusController = new SupportWorkersStatus( diff --git a/support-frontend/app/wiring/Services.scala b/support-frontend/app/wiring/Services.scala index 5dae2bf660..3f3f324aa8 100644 --- a/support-frontend/app/wiring/Services.scala +++ b/support-frontend/app/wiring/Services.scala @@ -29,6 +29,8 @@ trait Services { lazy val identityService = IdentityService(appConfig.identity) + lazy val userBenefitsApiServiceProvider = new UserBenefitsApiServiceProvider(appConfig.userBenefitsApiConfigProvider) + lazy val goCardlessServiceProvider = new GoCardlessFrontendServiceProvider(appConfig.goCardlessConfigProvider) lazy val supportWorkersClient = { diff --git a/support-frontend/assets/helpers/forms/errorReasons.ts b/support-frontend/assets/helpers/forms/errorReasons.ts index b3ed1a76fc..4ff31da100 100644 --- a/support-frontend/assets/helpers/forms/errorReasons.ts +++ b/support-frontend/assets/helpers/forms/errorReasons.ts @@ -21,6 +21,7 @@ const errorReasons = [ 'email_provider_rejected', 'invalid_email_address', 'recaptcha_validation_failed', + 'guardian_ad_lite_purchase_not_allowed', 'unknown', ] as const; export function isErrorReason(value: string): value is ErrorReason { @@ -83,6 +84,9 @@ function appropriateErrorMessage(errorReason: string): string { case 'recaptcha_validation_failed': return 'Please prove you are not a robot'; + + case 'guardian_ad_lite_purchase_not_allowed': + return 'You already have Guardian Ad-Lite or can read the Guardian ad-free, please sign in'; } } return 'The transaction was temporarily declined. Please try entering your payment details again. Alternatively, try another payment method.';