membership-attribute-service/app/controllers/AccountController.scala (336 lines of code) (raw):
package controllers
import actions._
import com.gu.i18n.Currency
import com.gu.memsub
import com.gu.memsub.BillingPeriod
import com.gu.memsub.BillingPeriod.RecurringPeriod
import com.gu.memsub.Product.Contribution
import com.gu.memsub.subsv2.{ProductType, Subscription}
import com.gu.monitoring.SafeLogger.LogPrefix
import com.gu.monitoring.SafeLogging
import com.gu.salesforce.Contact
import components.TouchpointComponents
import models.AccessScope.{completeReadSelf, readSelf, updateSelf}
import models.ApiErrors._
import models._
import monitoring.CreateMetrics
import org.joda.time.LocalDate
import play.api.data.Form
import play.api.data.Forms._
import play.api.libs.json.{Format, Json}
import play.api.mvc._
import scalaz._
import scalaz.std.scalaFuture._
import services._
import services.mail.Emails.{subscriptionCancelledEmail, updateAmountEmail}
import services.mail.SendEmail
import utils.SimpleEitherT
import utils.SimpleEitherT.SimpleEitherT
import scala.concurrent.{ExecutionContext, Future}
object AccountHelpers {
sealed trait OptionalSubscriptionsFilter
case class FilterBySubName(subscriptionNumber: memsub.Subscription.SubscriptionNumber) extends OptionalSubscriptionsFilter
case class FilterByProductType(productType: String) extends OptionalSubscriptionsFilter
case object NoFilter extends OptionalSubscriptionsFilter
def subscriptionSelector(
subscriptionNumber: memsub.Subscription.SubscriptionNumber,
messageSuffix: String,
subscriptions: List[Subscription],
): Either[String, Subscription] =
subscriptions.find(_.subscriptionNumber == subscriptionNumber).toRight(s"$subscriptionNumber was not a subscription for $messageSuffix")
def annotateFailableFuture[SuccessValue](failableFuture: Future[SuccessValue], action: String)(implicit
executionContext: ExecutionContext,
): Future[Either[String, SuccessValue]] =
failableFuture.map(Right(_)).recover { case exception =>
Left(s"failed to $action. Exception : $exception")
}
}
case class CancellationEffectiveDate(cancellationEffectiveDate: String)
object CancellationEffectiveDate {
implicit val cancellationEffectiveDateFormat: Format[CancellationEffectiveDate] = Json.format[CancellationEffectiveDate]
}
class AccountController(
commonActions: CommonActions,
override val controllerComponents: ControllerComponents,
contributionsStoreDatabaseService: ContributionsStoreDatabaseService,
sendEmail: SendEmail,
createMetrics: CreateMetrics,
) extends BaseController
with SafeLogging {
import AccountHelpers._
import commonActions._
implicit val executionContext: ExecutionContext = controllerComponents.executionContext
val metrics = createMetrics.forService(classOf[AccountController])
private def CancelError(details: String, code: Int): ApiError = ApiError("Failed to cancel subscription", details, code)
def extractCancellationReason(cancelForm: Form[String])(implicit request: play.api.mvc.Request[_], logPrefix: LogPrefix): Option[String] =
cancelForm
.bindFromRequest()
.value
def cancelSubscription(subscriptionNameString: String): Action[AnyContent] =
AuthorizeForScopes(requiredScopes = List(readSelf, updateSelf)).async { implicit request =>
import request.logPrefix
metrics.measureDuration("POST /user-attributes/me/cancel/:subscriptionName") {
val services = request.touchpoint
val subscriptionNumber = memsub.Subscription.SubscriptionNumber(subscriptionNameString)
val cancelForm = Form {
single("reason" -> nonEmptyText)
}
val identityId = request.user.identityId
def flatten[T](future: Future[\/[String, Option[T]]], errorMessage: String): SimpleEitherT[T] =
SimpleEitherT(future.map(_.toEither.flatMap(_.toRight(errorMessage))))
(for {
contact <-
flatten(
services.contactRepository.get(identityId),
s"No Salesforce user: $identityId",
).leftMap(CancelError(_, 404))
subscription <- SimpleEitherT(
services.subscriptionService
.current(contact)
.map(subs => subscriptionSelector(subscriptionNumber, s"Salesforce user $contact", subs)),
).leftMap(CancelError(_, 404))
accountId <-
(if (subscription.subscriptionNumber == subscriptionNumber)
SimpleEitherT.right(subscription.accountId)
else
SimpleEitherT.left(s"$subscriptionNumber does not belong to $identityId"))
.leftMap(CancelError(_, 503))
cancellationEffectiveDate <- services.subscriptionService.decideCancellationEffectiveDate(subscriptionNumber).leftMap(CancelError(_, 500))
cancellationReason = extractCancellationReason(cancelForm)
_ <- services.cancelSubscription.cancel(
subscriptionNumber,
cancellationEffectiveDate,
cancellationReason,
accountId,
subscription.termEndDate,
)
result = cancellationEffectiveDate.getOrElse("now").toString
catalog <- EitherT.rightT(services.futureCatalog)
_ <- sendSubscriptionCancelledEmail(
request.user.primaryEmailAddress,
contact,
subscription.plan(catalog).productType(catalog),
cancellationEffectiveDate,
)
} yield result).run.map(_.toEither).map {
case Left(apiError) =>
logger.error(scrub"Failed to cancel subscription for user $identityId because $apiError")
apiError
case Right(cancellationEffectiveDate) =>
logger.info(s"Successfully cancelled subscription $subscriptionNumber owned by $identityId")
Ok(Json.toJson(CancellationEffectiveDate(cancellationEffectiveDate)))
}
}
}
def getCancellationEffectiveDate(subscriptionName: String): Action[AnyContent] =
AuthorizeForScopes(requiredScopes = List(readSelf)).async { implicit request =>
import request.logPrefix
metrics.measureDuration("GET /user-attributes/me/cancellation-date/:subscriptionName") {
val services = request.touchpoint
val userId = request.user.identityId
(for {
cancellationEffectiveDate <- services.subscriptionService
.decideCancellationEffectiveDate(memsub.Subscription.SubscriptionNumber(subscriptionName))
.leftMap(error => ApiError("Failed to determine effectiveCancellationDate", error, 500))
result = cancellationEffectiveDate.getOrElse("now").toString
} yield result).run.map(_.toEither).map {
case Left(apiError) =>
logger.error(scrub"Failed to determine effectiveCancellationDate for $userId and $subscriptionName because $apiError")
apiError
case Right(cancellationEffectiveDate) =>
logger.info(
s"Successfully determined cancellation effective date for $subscriptionName owned by $userId as $cancellationEffectiveDate",
)
Ok(Json.toJson(CancellationEffectiveDate(cancellationEffectiveDate)))
}
}
}
def reminders: Action[AnyContent] =
AuthorizeForRecentLogin(Return401IfNotSignedInRecently, requiredScopes = List(completeReadSelf)).async { implicit request =>
import request.logPrefix
metrics.measureDuration("GET /user-attributes/me/reminders") {
request.redirectAdvice.userId match {
case Some(userId) =>
contributionsStoreDatabaseService.getSupportReminders(userId).map {
case Left(databaseError) =>
logger.error(scrub"DBERROR in reminders: $databaseError")
InternalServerError
case Right(supportReminders) =>
Ok(Json.toJson(supportReminders))
}
case None => Future.successful(InternalServerError)
}
}
}
def anyPaymentDetails(filter: OptionalSubscriptionsFilter, metricName: String): Action[AnyContent] =
AuthorizeForRecentLoginAndScopes(Return401IfNotSignedInRecently, requiredScopes = List(completeReadSelf)).async { request =>
import request.logPrefix
metrics.measureDuration(metricName) {
val user = request.user
val userId = user.identityId
logger.info(s"Attempting to retrieve payment details for identity user: $userId")
for {
catalog <- request.touchpoint.futureCatalog
result <- paymentDetails(userId, filter, request.touchpoint).toEither
} yield result match {
case Right(subscriptionList) =>
logger.info(s"Successfully retrieved payment details result for identity user: $userId")
val productsResponseWrites = new ProductsResponseWrites(catalog)
val response = productsResponseWrites.from(user, subscriptionList)
import productsResponseWrites.writes
Ok(Json.toJson(response))
case Left(message) =>
logger.warn(s"Unable to retrieve payment details result for identity user $userId due to $message")
InternalServerError("Failed to retrieve payment details due to an internal error")
}
}
}
private def paymentDetails(
userId: String,
filter: OptionalSubscriptionsFilter,
touchpointComponents: TouchpointComponents,
)(implicit logPrefix: LogPrefix): SimpleEitherT[List[AccountDetails]] = {
for {
fromZuora <- touchpointComponents.accountDetailsFromZuora.fetch(userId, filter)
fromStripe <- touchpointComponents.guardianPatronService.getGuardianPatronAccountDetails(userId)
} yield fromZuora ++ fromStripe
}
def fetchCancelledSubscriptions(): Action[AnyContent] =
AuthorizeForRecentLogin(Return401IfNotSignedInRecently, requiredScopes = List(completeReadSelf)).async { implicit request =>
import request.logPrefix
metrics.measureDuration("GET /user-attributes/me/cancelled-subscriptions") {
implicit val tp: TouchpointComponents = request.touchpoint
val emptyResponse = Ok("[]")
request.redirectAdvice.userId match {
case Some(identityId) =>
for {
catalog <- tp.futureCatalog
result <-
(for {
contact <- OptionT(EitherT(tp.contactRepository.get(identityId)))
subs <- OptionT(EitherT(tp.subscriptionService.recentlyCancelled(contact)).map(Option(_)))
} yield {
Ok(Json.toJson(subs.map(CancelledSubscription(_, catalog))))
}).getOrElse(emptyResponse).leftMap(_ => emptyResponse).merge // we discard errors as this is not critical endpoint
} yield result
case None => Future.successful(unauthorized)
}
}
}
def updateContributionAmount(subscriptionName: String): Action[AnyContent] =
AuthorizeForScopes(requiredScopes = List(readSelf, updateSelf)).async { implicit request =>
import request.logPrefix
metrics.measureDuration("POST /user-attributes/me/contribution-update-amount/:subscriptionName") {
val services = request.touchpoint
val userId = request.user.identityId
val email = request.user.primaryEmailAddress
logger.info(s"Attempting to update contribution amount for $userId")
(for {
newPrice <- SimpleEitherT.fromEither(validateContributionAmountUpdateForm(request))
user <- SimpleEitherT.right(userId)
contact <- SimpleEitherT.fromFutureOption(services.contactRepository.get(user), s"No SF user $user")
subscription <- SimpleEitherT(
services.subscriptionService
.current(contact)
.map(subs => subscriptionSelector(memsub.Subscription.SubscriptionNumber(subscriptionName), s"the sfUser $contact", subs)),
)
catalog <- SimpleEitherT.rightT(services.futureCatalog)
contributionPlan = subscription.plan(catalog)
_ <- SimpleEitherT.fromEither(contributionPlan.product(catalog) match {
case Contribution => Right(())
case nc => Left(s"$subscriptionName plan is not a contribution: " + nc)
})
billingPeriod <- SimpleEitherT.fromEither(contributionPlan.billingPeriod.toEither)
recurringPeriod <- SimpleEitherT.fromEither(billingPeriod match {
case period: RecurringPeriod => Right(period)
case period: BillingPeriod.OneOffPeriod => Left(s"period $period was not recurring for contribution update")
})
applyFromDate = contributionPlan.chargedThroughDate.getOrElse(contributionPlan.effectiveStartDate)
currency = contributionPlan.chargesPrice.prices.head.currency
currencyGlyph = currency.glyph
oldPrice = contributionPlan.chargesPrice.prices.head.amount
reasonForChange =
s"User updated contribution via self-service MMA. Amount changed from $currencyGlyph$oldPrice to $currencyGlyph$newPrice effective from $applyFromDate"
result <- SimpleEitherT(
services.zuoraRestService.updateChargeAmount(
subscription.subscriptionNumber,
contributionPlan.ratePlanCharges.head.id,
contributionPlan.id,
newPrice.toDouble,
reasonForChange,
applyFromDate,
),
).leftMap(message => s"Error while updating contribution amount: $message")
_ <- sendUpdateAmountEmail(newPrice, email, contact, currency, recurringPeriod, applyFromDate)
} yield result).run.map(_.toEither) map {
case Left(message) =>
logger.error(scrub"Failed to update payment amount for user $userId, due to: $message")
InternalServerError(message)
case Right(()) =>
logger.info(s"Contribution amount updated for user $userId")
Ok("Success")
}
}
}
private def sendUpdateAmountEmail(
newPrice: BigDecimal,
email: String,
contact: Contact,
currency: Currency,
billingPeriod: RecurringPeriod,
nextPaymentDate: LocalDate,
)(implicit logPrefix: LogPrefix) =
SimpleEitherT.right(sendEmail.send(updateAmountEmail(email, contact, newPrice, currency, billingPeriod, nextPaymentDate)))
private def sendSubscriptionCancelledEmail(
email: String,
contact: Contact,
productType: ProductType,
cancellationEffectiveDate: Option[LocalDate],
)(implicit logPrefix: LogPrefix) =
SimpleEitherT
.right(sendEmail.send(subscriptionCancelledEmail(email, contact, productType, cancellationEffectiveDate)))
.leftMap(ApiError(_, "Email could not be put on the queue", 500))
private[controllers] def validateContributionAmountUpdateForm(implicit request: Request[AnyContent]): Either[String, BigDecimal] = {
val minAmount = 1
for {
amount <- Form(single("newPaymentAmount" -> bigDecimal(5, 2))).bindFromRequest().value.toRight("no new payment amount submitted with request")
validAmount <- Either.cond(amount >= minAmount, amount, s"New payment amount '$amount' is too small")
} yield validAmount
}
def updateCancellationReason(subscriptionName: String): Action[AnyContent] =
AuthorizeForScopes(requiredScopes = List(readSelf, updateSelf)).async { implicit request =>
import request.logPrefix
metrics.measureDuration("POST /user-attributes/me/update-cancellation-reason/:subscriptionName") {
val subName = memsub.Subscription.SubscriptionNumber(subscriptionName)
val services = request.touchpoint
val cancelForm = Form {
single("reason" -> nonEmptyText)
}
val identityId = request.user.identityId
val cancellationReasonOption = extractCancellationReason(cancelForm)
cancellationReasonOption match {
case Some(cancellationReason) =>
services.zuoraRestService.updateCancellationReason(subName, cancellationReason).map {
case -\/(error) =>
logger.error(scrub"Failed to update cancellation reason for user $identityId because $error")
InternalServerError(s"Failed to update cancellation reason with error: $error")
case \/-(_) =>
logger.info(s"Successfully updated cancellation reason for subscription $subscriptionName owned by $identityId")
NoContent
}
case None =>
Future.successful(BadRequest(Json.toJson(badRequest("Malformed request. Expected a valid reason for cancellation."))))
}
}
}
def allPaymentDetails(productType: Option[String]): Action[AnyContent] =
anyPaymentDetails(
productType.fold[OptionalSubscriptionsFilter](NoFilter)(FilterByProductType.apply),
"GET /user-attributes/me/mma",
)
def paymentDetailsSpecificSub(subscriptionName: String): Action[AnyContent] =
anyPaymentDetails(
FilterBySubName(memsub.Subscription.SubscriptionNumber(subscriptionName)),
"GET /user-attributes/me/mma/:subscriptionName",
)
}