membership-attribute-service/app/services/PaymentFailureAlerter.scala (126 lines of code) (raw):
package services
import com.gu.memsub.Product
import com.gu.memsub.Product.{Contribution, Membership}
import com.gu.memsub.Subscription.AccountId
import com.gu.memsub.subsv2.{Catalog, Subscription}
import com.gu.monitoring.SafeLogger.LogPrefix
import com.gu.monitoring.SafeLogging
import com.gu.zuora.api.{RegionalStripeGateways, StripeAUMembershipGateway, StripeUKMembershipGateway}
import org.joda.time.DateTime
import org.joda.time.format.DateTimeFormat
import scalaz.syntax.std.boolean._
import services.zuora.rest.ZuoraRestService.{AccountObject, AccountSummary, Invoice, Payment, PaymentMethodId, PaymentMethodResponse}
import java.util.Locale
import scala.concurrent.{ExecutionContext, Future}
object PaymentFailureAlerter extends SafeLogging {
private def accountObject(accountSummary: AccountSummary) =
AccountObject(
Id = accountSummary.id,
Balance = accountSummary.balance,
Currency = accountSummary.currency,
DefaultPaymentMethodId = accountSummary.defaultPaymentMethod.map(_.id),
PaymentGateway = accountSummary.billToContact.country.map(RegionalStripeGateways.getGatewayForCountry),
LastInvoiceDate = latestUnpaidInvoiceDate(accountSummary.invoices),
)
def latestUnpaidInvoiceDate(invoices: List[Invoice]): Option[DateTime] = {
implicit def latestFirstDateTimeOrdering: Ordering[DateTime] = Ordering.fromLessThan(_ isAfter _)
val unpaidInvoices = invoices.filter(invoice => invoice.balance > 0 && invoice.status == "Posted")
val latestUnpaidInvoice = unpaidInvoices.sortBy(invoice => invoice.invoiceDate).headOption
latestUnpaidInvoice.map(_.invoiceDate)
}
def alertText(
accountSummary: AccountSummary,
subscription: Subscription,
paymentMethodGetter: PaymentMethodId => Future[Either[String, PaymentMethodResponse]],
catalog: Catalog,
)(implicit ec: ExecutionContext, logPrefix: LogPrefix): Future[Option[String]] = {
def expectedAlertText: Future[Option[String]] = {
val formatter = DateTimeFormat.forPattern("d MMMM yyyy").withLocale(Locale.ENGLISH)
val maybePaymentMethodLatestDate: Future[Option[DateTime]] = accountSummary.defaultPaymentMethod.map(_.id) match {
case Some(id) =>
val paymentMethod: Future[Either[String, PaymentMethodResponse]] =
paymentMethodGetter(id) fallbackTo Future.successful(Left("Failed to get payment method"))
paymentMethod.map(_.map(_.lastTransactionDateTime).toOption)
case None => Future.successful(None)
}
def getProductDescription(subscription: Subscription) =
if (subscription.ratePlans.head.product(catalog) == Membership) {
s"${subscription.plan(catalog).productName} membership"
} else if (subscription.ratePlans.head.product(catalog) == Contribution) {
"contribution"
} else {
subscription.plan(catalog).productName
}
maybePaymentMethodLatestDate map { maybeDate: Option[DateTime] =>
maybeDate map { latestDate: DateTime =>
val productDescription = getProductDescription(subscription)
logger.info(
s"Logging an alert for identityId: ${accountSummary.identityId} accountId: ${accountSummary.id}. Payment failed on ${latestDate.toString(formatter)}",
)
s"Our attempt to take payment for your $productDescription failed on ${latestDate.toString(formatter)}."
}
}
}
alertAvailableFor(accountObject(accountSummary), subscription, paymentMethodGetter, catalog) flatMap { shouldShowAlert: Boolean =>
expectedAlertText.map { someText => shouldShowAlert.option(someText).flatten }
}
}
val nonAlertableProducts: List[Product] = List()
def alertAvailableFor(
account: AccountObject,
subscription: Subscription,
paymentMethodGetter: PaymentMethodId => Future[Either[String, PaymentMethodResponse]],
catalog: Catalog,
)(implicit ec: ExecutionContext): Future[Boolean] = {
def isAlertableProduct = !nonAlertableProducts.contains(subscription.plan(catalog).product(catalog))
def creditCard(paymentMethodResponse: PaymentMethodResponse) =
paymentMethodResponse.paymentMethodType == "CreditCardReferenceTransaction" || paymentMethodResponse.paymentMethodType == "CreditCard"
val stillFreshInDays = 27
def recentEnough(lastInvoiceDateTime: DateTime) = lastInvoiceDateTime.plusDays(stillFreshInDays).isAfterNow
val isActionablePaymentGateway = account.PaymentGateway.exists(gw => gw == StripeUKMembershipGateway || gw == StripeAUMembershipGateway)
def hasFailureForCreditCardPaymentMethod(paymentMethodId: PaymentMethodId): Future[Either[String, Boolean]] = {
val eventualPaymentMethod: Future[Either[String, PaymentMethodResponse]] = paymentMethodGetter(paymentMethodId)
eventualPaymentMethod map { maybePaymentMethod: Either[String, PaymentMethodResponse] =>
maybePaymentMethod.map { pm: PaymentMethodResponse =>
creditCard(pm) && pm.numConsecutiveFailures > 0
}
}
}
val alertAvailable = for {
invoiceDate: DateTime <- account.LastInvoiceDate
paymentMethodId: PaymentMethodId <- account.DefaultPaymentMethodId
} yield {
if (
isAlertableProduct &&
account.Balance > 0 &&
isActionablePaymentGateway &&
!subscription.isCancelled &&
recentEnough(invoiceDate)
) hasFailureForCreditCardPaymentMethod(paymentMethodId)
else Future.successful(Right(false))
}.map(_.getOrElse(false))
alertAvailable.getOrElse(Future.successful(false))
}
// Ignore unpaid invoices which are less than a month old, because these should be automatically dealt with by the payment retry process
def mostRecentPayableInvoicesOlderThanOneMonth(recentInvoices: List[Invoice]): List[Invoice] = {
implicit def localDateOrdering: Ordering[DateTime] = Ordering.fromLessThan(_ isAfter _)
val nonZeroInvoicesOlderThanOneMonth =
recentInvoices.filter(invoice => invoice.invoiceDate.isBefore(DateTime.now().minusMonths(1)) && invoice.amount > 0)
nonZeroInvoicesOlderThanOneMonth.sortBy(_.invoiceDate).take(2)
}
def accountHasMissedPayments(accountId: AccountId, recentInvoices: List[Invoice], recentPayments: List[Payment])(implicit
logPrefix: LogPrefix,
): Boolean = {
val paidInvoiceNumbers = recentPayments.filter(_.status == "Processed").flatMap(_.paidInvoices).map(_.invoiceNumber)
val unpaidPayableInvoiceOlderThanOneMonth = mostRecentPayableInvoicesOlderThanOneMonth(recentInvoices) match {
case Nil => false
case invoices => !invoices.forall(invoice => paidInvoiceNumbers.contains(invoice.invoiceNumber))
}
logger.info(s"${accountId.get} | accountHasMissedPayments: ${unpaidPayableInvoiceOlderThanOneMonth}")
unpaidPayableInvoiceOlderThanOneMonth
}
def safeToAllowPaymentUpdate(accountId: AccountId, recentInvoices: List[Invoice])(implicit logPrefix: LogPrefix): Boolean = {
val result = !recentInvoices.exists(invoice => invoice.balance > 0 && invoice.invoiceDate.isBefore(DateTime.now.minusMonths(1)))
logger.info(s"${accountId.get} | safeToAllowPaymentUpdate: ${result}")
result
}
}