Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prevent Guardian Ad-Lite purchase by users who have either of adFree or allowRejectAll benefits already #6675

Merged
merged 13 commits into from
Jan 23, 2025
Merged
3 changes: 3 additions & 0 deletions support-config/src/main/resources/touchpoint.CODE.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions support-config/src/main/resources/touchpoint.PROD.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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"),
)
}
}
2 changes: 2 additions & 0 deletions support-frontend/app/config/Configuration.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
tjmw marked this conversation as resolved.
Show resolved Hide resolved
.getUserBenefits(userDetails.userDetails.identityId)
.leftMap(_ => ServerError("Something went wrong calling the user benefits API"))
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there's an issue with calling the user benefits API the purchase will be blocked and we'll show a generic error message to the user.

_ <- 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 =
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this now be "maybeUserDetails" since the signed in status can vary?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had this same thought and decided to leave it as is because in the case of this val it's still either:

  1. None or
  2. The details of a user who is signed in.

I.e. maybeLoggedInUserDetails won't ever contain information about a user who isn't signed in. Though like you say it's true that the Option type UserDetailsWithSignedInStatus can represent this state. What do you reckon?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, right. That's quite subtle but seems fine to leave as is

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 {
Expand All @@ -157,17 +214,21 @@ 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)
tjmw marked this conversation as resolved.
Show resolved Hide resolved

supportWorkersUser = buildSupportWorkersUser(userDetails.userDetails, request.body, testUsers.isTestUser(request))

statusResponse <- client
.createSubscription(request, supportWorkersUser, request.uuid)
.leftMap[CreateSubscriptionError](ServerError)

} yield CreateSubscriptionResponse(
statusResponse.status,
userDetails.userType,
userDetails.userDetails.userType,
statusResponse.trackingUri,
statusResponse.failureReason,
)
Expand Down
57 changes: 57 additions & 0 deletions support-frontend/app/services/UserBenefitsApiService.scala
Original file line number Diff line number Diff line change
@@ -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)
}
}
15 changes: 15 additions & 0 deletions support-frontend/app/services/UserBenefitsApiServiceProvider.scala
Original file line number Diff line number Diff line change
@@ -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)
}
1 change: 1 addition & 0 deletions support-frontend/app/wiring/Controllers.scala
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ trait Controllers {
controllerComponents,
appConfig.guardianDomain,
paperRoundServiceProvider,
userBenefitsApiServiceProvider,
)

lazy val supportWorkersStatusController = new SupportWorkersStatus(
Expand Down
2 changes: 2 additions & 0 deletions support-frontend/app/wiring/Services.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
4 changes: 4 additions & 0 deletions support-frontend/assets/helpers/forms/errorReasons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.';
Expand Down
Loading