membership-attribute-service/app/services/AccountDetailsFromZuora.scala (210 lines of code) (raw):
package services
import com.gu.memsub.Product
import com.gu.memsub.Subscription.SubscriptionNumber
import com.gu.memsub.subsv2.{Catalog, Subscription}
import com.gu.memsub.subsv2.services.SubscriptionService
import com.gu.monitoring.SafeLogger.LogPrefix
import com.gu.salesforce.Contact
import com.gu.services.model.PaymentDetails
import controllers.AccountController
import controllers.AccountHelpers.{FilterByProductType, FilterBySubName, NoFilter, OptionalSubscriptionsFilter}
import models.{AccountDetails, ContactAndSubscription, DeliveryAddress}
import monitoring.CreateMetrics
import scalaz.ListT
import scalaz.std.scalaFuture._
import services.PaymentFailureAlerter.{accountHasMissedPayments, alertText, safeToAllowPaymentUpdate}
import services.salesforce.ContactRepository
import services.stripe.ChooseStripe
import services.zuora.rest.ZuoraRestService
import services.zuora.rest.ZuoraRestService.PaymentMethodId
import utils.ListTEither.ListTEither
import utils.SimpleEitherT.SimpleEitherT
import utils.{ListTEither, SimpleEitherT}
import scala.concurrent.{ExecutionContext, Future}
class AccountDetailsFromZuora(
createMetrics: CreateMetrics,
zuoraRestService: ZuoraRestService,
contactRepository: ContactRepository,
subscriptionService: SubscriptionService[Future],
chooseStripe: ChooseStripe,
paymentDetailsForSubscription: PaymentDetailsForSubscription,
futureCatalog: LogPrefix => Future[Catalog],
)(implicit executionContext: ExecutionContext) {
private val metrics = createMetrics.forService(classOf[AccountController])
def fetch(userId: String, filter: OptionalSubscriptionsFilter)(implicit logPrefix: LogPrefix): SimpleEitherT[List[AccountDetails]] = {
metrics.measureDurationEither("accountDetailsFromZuora") {
SimpleEitherT.fromListT(accountDetailsFromZuoraFor(userId, filter))
}
}
private def accountDetailsFromZuoraFor(userId: String, filter: OptionalSubscriptionsFilter)(implicit
logPrefix: LogPrefix,
): ListT[SimpleEitherT, AccountDetails] = {
for {
catalog <- ListTEither.singleRightT(futureCatalog(logPrefix))
contactAndSubscription <- allCurrentSubscriptions(userId, filter)
detailsResultsTriple <- ListTEither.single(getAccountDetailsParallel(contactAndSubscription))
(paymentDetails, accountSummary, effectiveCancellationDate) = detailsResultsTriple
country = accountSummary.billToContact.country
stripePublicKey = chooseStripe.publicKeyForCountry(country)
alertText <- ListTEither.singleRightT(alertText(accountSummary, contactAndSubscription.subscription, getPaymentMethod, catalog))
isAutoRenew = contactAndSubscription.subscription.autoRenew
} yield {
AccountDetails(
contactId = contactAndSubscription.contact.salesforceContactId,
regNumber = None,
email = accountSummary.billToContact.email,
deliveryAddress = Some(DeliveryAddress.fromContact(contactAndSubscription.contact)),
subscription = contactAndSubscription.subscription,
paymentDetails = paymentDetails,
billingCountry = accountSummary.billToContact.country,
stripePublicKey = stripePublicKey.key,
accountHasMissedRecentPayments =
accountHasMissedPayments(contactAndSubscription.subscription.accountId, accountSummary.invoices, accountSummary.payments),
safeToUpdatePaymentMethod = safeToAllowPaymentUpdate(contactAndSubscription.subscription.accountId, accountSummary.invoices),
isAutoRenew = isAutoRenew,
alertText = alertText,
accountId = accountSummary.id.get,
cancellationEffectiveDate = effectiveCancellationDate,
)
}
}
private def getPaymentMethod(id: PaymentMethodId)(implicit logPrefix: LogPrefix): Future[Either[String, ZuoraRestService.PaymentMethodResponse]] =
zuoraRestService.getPaymentMethod(id.get).map(_.toEither)
private def nonGiftContactAndSubscriptionsFor(contact: Contact)(implicit logPrefix: LogPrefix): Future[List[ContactAndSubscription]] = {
subscriptionService
.current(contact)
.map(_.map(ContactAndSubscription(contact, _, isGiftRedemption = false)))
}
private def applyFilter(
filter: OptionalSubscriptionsFilter,
contactAndSubscriptions: List[ContactAndSubscription],
catalog: Catalog,
): List[ContactAndSubscription] = {
filter match {
case FilterBySubName(subscriptionName) =>
contactAndSubscriptions.find(_.subscription.subscriptionNumber == subscriptionName).toList
case FilterByProductType(productType) =>
contactAndSubscriptions.filter(contactAndSubscription =>
productIsInstanceOfProductType(
contactAndSubscription.subscription.plan(catalog).product(catalog),
productType,
),
)
case NoFilter =>
contactAndSubscriptions
}
}
private def subscriptionsFor(userId: String, contact: Contact, filter: OptionalSubscriptionsFilter)(implicit
logPrefix: LogPrefix,
): SimpleEitherT[List[ContactAndSubscription]] = {
for {
nonGiftContactAndSubscriptions <- SimpleEitherT.rightT(nonGiftContactAndSubscriptionsFor(contact))
contactAndSubscriptions <- checkForGiftSubscription(userId, nonGiftContactAndSubscriptions, contact)
catalog <- SimpleEitherT.rightT(futureCatalog(logPrefix))
subsWithRecognisedProducts = contactAndSubscriptions.filter(_.subscription.plan(catalog).product(catalog) match {
case _: Product.ContentSubscription => true
case Product.UnknownProduct => false
case Product.Membership => true
case Product.GuardianPatron => true
case Product.Contribution => true
case Product.Discounts => false
case Product.AdLite => true
})
filtered = applyFilter(filter, subsWithRecognisedProducts, catalog)
} yield filtered
}
private def allCurrentSubscriptions(
userId: String,
filter: OptionalSubscriptionsFilter,
)(implicit logPrefix: LogPrefix): ListTEither[ContactAndSubscription] = {
for {
contact <- ListTEither.fromFutureOption(contactRepository.get(userId))
subscription <- ListTEither.fromEitherT(subscriptionsFor(userId, contact, filter))
} yield subscription
}
private def getAccountDetailsParallel(
contactAndSubscription: ContactAndSubscription,
)(implicit logPrefix: LogPrefix): SimpleEitherT[(PaymentDetails, ZuoraRestService.AccountSummary, Option[String])] = {
metrics.measureDurationEither("getAccountDetailsParallel") {
// Run all these api calls in parallel to improve response times
val paymentDetailsFuture =
paymentDetailsForSubscription
.getPaymentDetails(contactAndSubscription)
.map(Right(_))
.recover { case x =>
Left(s"error retrieving payment details for subscription: ${contactAndSubscription.subscription.subscriptionNumber}. Reason: $x")
}
val accountSummaryFuture =
zuoraRestService
.getAccount(contactAndSubscription.subscription.accountId)
.map(_.toEither)
.recover { case x =>
Left(
s"error receiving account summary for subscription: ${contactAndSubscription.subscription.subscriptionNumber} " +
s"with account id ${contactAndSubscription.subscription.accountId}. Reason: $x",
)
}
val effectiveCancellationDateFuture =
zuoraRestService
.getCancellationEffectiveDate(contactAndSubscription.subscription.subscriptionNumber)
.map(_.toEither)
.recover { case x =>
Left(
s"Failed to fetch effective cancellation date: ${contactAndSubscription.subscription.subscriptionNumber} " +
s"with account id ${contactAndSubscription.subscription.accountId}. Reason: $x",
)
}
for {
paymentDetails <- SimpleEitherT(paymentDetailsFuture)
accountSummary <- SimpleEitherT(accountSummaryFuture)
effectiveCancellationDate <- SimpleEitherT(effectiveCancellationDateFuture)
} yield (paymentDetails, accountSummary, effectiveCancellationDate)
}
}
private def checkForGiftSubscription(
userId: String,
nonGiftSubscription: List[ContactAndSubscription],
contact: Contact,
)(implicit logPrefix: LogPrefix): SimpleEitherT[List[ContactAndSubscription]] = {
metrics.measureDurationEither("checkForGiftSubscription") {
for {
records <- SimpleEitherT(zuoraRestService.getGiftSubscriptionRecordsFromIdentityId(userId))
reused <- reuseAlreadyFetchedSubscriptionIfAvailable(records, nonGiftSubscription)
contactAndSubscriptions = reused.map(ContactAndSubscription(contact, _, isGiftRedemption = true))
} yield contactAndSubscriptions ++ nonGiftSubscription
}
}
private def reuseAlreadyFetchedSubscriptionIfAvailable(
giftRecords: List[ZuoraRestService.GiftSubscriptionsFromIdentityIdRecord],
nonGiftSubs: List[ContactAndSubscription],
)(implicit logPrefix: LogPrefix): SimpleEitherT[List[Subscription]] = {
val all = giftRecords.map { giftRecord =>
val subscriptionNumber = SubscriptionNumber(giftRecord.Name)
// If the current user is both the gifter and the giftee we will have already retrieved their
// subscription so we can reuse it and avoid a call to Zuora
val matchingSubscription: Option[ContactAndSubscription] = nonGiftSubs.find(_.subscription.subscriptionNumber == subscriptionNumber)
matchingSubscription
.map(contactAndSubscription => Future.successful(Some(contactAndSubscription.subscription)))
.getOrElse(subscriptionService.get(subscriptionNumber, isActiveToday = false))
}
val result: Future[List[Subscription]] = Future.sequence(all).map(_.flatten)
SimpleEitherT.rightT(result) // failures turn to None, and are logged, so just ignore them
}
private def productIsInstanceOfProductType(product: Product, requestedProductType: String) = {
val requestedProductTypeIsContentSubscription: Boolean = requestedProductType == "ContentSubscription"
product match {
// this ordering prevents Weekly subs from coming back when Paper is requested (which is different from the type hierarchy where Weekly extends Paper)
case _: Product.Weekly => requestedProductType == "Weekly" || requestedProductTypeIsContentSubscription
case Product.Voucher => requestedProductType == "Voucher" || requestedProductType == "Paper" || requestedProductTypeIsContentSubscription
case Product.DigitalVoucher =>
requestedProductType == "DigitalVoucher" || requestedProductType == "Paper" || requestedProductTypeIsContentSubscription
case Product.Delivery =>
requestedProductType == "HomeDelivery" || requestedProductType == "Paper" || requestedProductTypeIsContentSubscription
case Product.NationalDelivery =>
requestedProductType == "HomeDelivery" || requestedProductType == "Paper" || requestedProductTypeIsContentSubscription
case Product.Contribution => requestedProductType == "Contribution"
case Product.Membership => requestedProductType == "Membership"
case Product.Digipack => requestedProductType == "Digipack" || requestedProductTypeIsContentSubscription
case Product.SupporterPlus => requestedProductType == "SupporterPlus" || requestedProductTypeIsContentSubscription
case Product.TierThree => requestedProductType == "TierThree" || requestedProductTypeIsContentSubscription
case Product.AdLite => requestedProductType == "GuardianAdLite"
case _ => requestedProductType == product.name // fallback
}
}
}