membership-attribute-service/app/models/AccountDetails.scala (229 lines of code) (raw):
package models
import com.gu.i18n.Country
import com.gu.memsub.ProductRatePlanChargeProductType.PaperDay
import com.gu.memsub._
import com.gu.memsub.subsv2._
import com.gu.monitoring.SafeLogger.LogPrefix
import com.gu.monitoring.SafeLogging
import com.gu.services.model.PaymentDetails
import json.localDateWrites
import org.joda.time.LocalDate
import org.joda.time.LocalDate.now
import play.api.libs.json._
import java.time.DayOfWeek
import java.time.format.TextStyle
import java.util.Locale
import scala.annotation.tailrec
case class AccountDetails(
contactId: String,
regNumber: Option[String],
email: Option[String],
deliveryAddress: Option[DeliveryAddress],
subscription: Subscription,
paymentDetails: PaymentDetails,
billingCountry: Option[Country],
stripePublicKey: String,
accountHasMissedRecentPayments: Boolean,
safeToUpdatePaymentMethod: Boolean,
isAutoRenew: Boolean,
alertText: Option[String],
accountId: String,
cancellationEffectiveDate: Option[String],
)
object AccountDetails {
implicit class ResultLike(accountDetails: AccountDetails) extends SafeLogging {
import accountDetails._
def toJson(catalog: Catalog)(implicit logPrefix: LogPrefix): JsObject = {
val product = accountDetails.subscription.plan(catalog).product(catalog)
val paymentMethod = paymentDetails.paymentMethod match {
case Some(payPal: PayPalMethod) =>
Json.obj(
"paymentMethod" -> "PayPal",
"payPalEmail" -> payPal.email,
)
case Some(card: PaymentCard) =>
Json.obj(
"paymentMethod" -> "Card",
"card" -> {
Json.obj(
"last4" -> card.paymentCardDetails.map(_.lastFourDigits).getOrElse[String]("••••"),
"expiry" -> card.paymentCardDetails.map(cardDetails =>
Json.obj(
"month" -> cardDetails.expiryMonth,
"year" -> cardDetails.expiryYear,
),
),
"type" -> card.cardType.getOrElse[String]("unknown"),
"stripePublicKeyForUpdate" -> stripePublicKey,
"email" -> email,
)
},
)
case Some(dd: GoCardless) =>
Json.obj(
"paymentMethod" -> "DirectDebit",
"account" -> Json.obj( // DEPRECATED
"accountName" -> dd.accountName,
),
"mandate" -> Json.obj(
"accountName" -> dd.accountName,
"accountNumber" -> dd.accountNumber,
"sortCode" -> dd.sortCode,
),
)
case Some(sepa: Sepa) =>
Json.obj(
"paymentMethod" -> "Sepa",
"sepaMandate" -> Json.obj(
"accountName" -> sepa.accountName,
"iban" -> sepa.accountNumber,
),
)
case _ if accountHasMissedRecentPayments && safeToUpdatePaymentMethod =>
Json.obj(
"paymentMethod" -> "ResetRequired",
"stripePublicKeyForCardAddition" -> stripePublicKey,
)
case _ => Json.obj()
}
def externalisePlanName(plan: RatePlan): Option[String] = plan.product(catalog) match {
case _: Product.Weekly => if (plan.name(catalog).contains("Six for Six")) Some("currently on '6 for 6'") else None
case _: Product.Paper => Some(plan.name(catalog).replace("+", " plus Digital Subscription"))
case _ => None
}
def maybePaperDaysOfWeek(plan: RatePlan) = {
val dayIndexes = for {
charge <- plan.ratePlanCharges.list.toList
.filterNot(_.pricing.isFree) // note 'Echo Legacy' rate plan has all days of week but some are zero price, this filters those out
catalogZuoraPlan <- catalog.productRatePlans.get(plan.productRatePlanId)
dayName <- catalogZuoraPlan.productRatePlanCharges
.get(charge.productRatePlanChargeId)
.collect { case benefit: PaperDay => benefit.dayOfTheWeekIndex }
} yield dayName
val dayNames = dayIndexes.sorted.map(DayOfWeek.of(_).getDisplayName(TextStyle.FULL, Locale.ENGLISH))
if (dayNames.nonEmpty) Json.obj("daysOfWeek" -> dayNames) else Json.obj()
}
def jsonifyPlan(plan: RatePlan) = Json.obj(
"name" -> externalisePlanName(plan),
"start" -> plan.effectiveStartDate,
"end" -> plan.effectiveEndDate,
// if the customer acceptance date is future dated (e.g. 6for6) then always display, otherwise only show if starting less than 30 days from today
"shouldBeVisible" -> (subscription.customerAcceptanceDate.isAfter(now) || plan.effectiveStartDate.isBefore(now.plusDays(30))),
"chargedThrough" -> plan.chargedThroughDate,
"price" -> (plan.chargesPrice.prices.head.amount * 100).toInt,
"currency" -> plan.chargesPrice.prices.head.currency.glyph,
"currencyISO" -> plan.chargesPrice.prices.head.currency.iso,
"billingPeriod" -> (plan.billingPeriod
.leftMap(e => logger.warn("unknown billing period: " + e))
.map(_.noun)
.getOrElse("unknown_billing_period"): String),
"features" -> plan.features.map(_.featureCode).mkString(","),
) ++ maybePaperDaysOfWeek(plan)
val subscriptionData = new FilterPlans(subscription, catalog)
val selfServiceCancellation = SelfServiceCancellation(product, billingCountry)
val start = subscriptionData.startDate.getOrElse(paymentDetails.customerAcceptanceDate)
val end = subscriptionData.endDate.getOrElse(paymentDetails.termEndDate)
Json.obj(
"tier" -> getTier(catalog, subscription.plan(catalog)),
"isPaidTier" -> (paymentDetails.plan.price.amount > 0f),
"selfServiceCancellation" -> Json.obj(
"isAllowed" -> selfServiceCancellation.isAllowed,
"shouldDisplayEmail" -> selfServiceCancellation.shouldDisplayEmail,
"phoneRegionsToDisplay" -> selfServiceCancellation.phoneRegionsToDisplay,
),
) ++
regNumber.fold(Json.obj())({ reg => Json.obj("regNumber" -> reg) }) ++
billingCountry.fold(Json.obj())({ bc => Json.obj("billingCountry" -> bc.name) }) ++
Json.obj(
"joinDate" -> paymentDetails.startDate,
"optIn" -> !paymentDetails.pendingCancellation,
"subscription" -> (paymentMethod ++ Json.obj(
"contactId" -> accountDetails.contactId,
"deliveryAddress" -> accountDetails.deliveryAddress,
"safeToUpdatePaymentMethod" -> safeToUpdatePaymentMethod,
"start" -> start,
"end" -> end,
"nextPaymentPrice" -> paymentDetails.nextPaymentPrice,
"nextPaymentDate" -> paymentDetails.nextPaymentDate,
"potentialCancellationDate" -> paymentDetails.nextInvoiceDate,
"lastPaymentDate" -> paymentDetails.lastPaymentDate,
"chargedThroughDate" -> paymentDetails.chargedThroughDate,
"renewalDate" -> paymentDetails.termEndDate,
"anniversaryDate" -> anniversary(start),
"cancelledAt" -> paymentDetails.pendingCancellation,
"subscriptionId" -> paymentDetails.subscriberId,
"trialLength" -> paymentDetails.remainingTrialLength,
"autoRenew" -> isAutoRenew,
"plan" -> Json.obj( // TODO remove once nothing is using this key (same time as removing old deprecated endpoints)
"name" -> paymentDetails.plan.name,
"price" -> (paymentDetails.plan.price.amount * 100).toInt,
"currency" -> paymentDetails.plan.price.currency.glyph,
"currencyISO" -> paymentDetails.plan.price.currency.iso,
"billingPeriod" -> paymentDetails.plan.interval.mkString,
),
"currentPlans" -> subscriptionData.currentPlans.map(jsonifyPlan),
"futurePlans" -> subscriptionData.futurePlans.map(jsonifyPlan),
"readerType" -> accountDetails.subscription.readerType.value,
"accountId" -> accountDetails.accountId,
"cancellationEffectiveDate" -> cancellationEffectiveDate,
)),
) ++ alertText.map(text => Json.obj("alertText" -> text)).getOrElse(Json.obj())
}
}
/** Note this is a different concept than termEndDate because termEndDate could be many years in the future. termEndDate models when Zuora will
* renew the subscription whilst anniversary indicates when another year will have passed since user started their subscription.
*
* @param start
* beginning of subscription timeline, perhaps customerAcceptanceDate
* @param today
* where we are on the timeline today
* @return
* next anniversary date of the subscription
*/
def anniversary(
start: LocalDate,
today: LocalDate = LocalDate.now(),
): LocalDate = {
@tailrec def loop(current: LocalDate): LocalDate = {
val next = current.plusYears(1)
if (today.isBefore(next)) next
else loop(next)
}
loop(start)
}
def getTier(catalog: Catalog, plan: RatePlan): Json.JsValueWrapper =
plan.product(catalog) match {
case Product.Delivery if plan.name(catalog) == "Sunday" => "Newspaper Delivery - Observer"
case Product.DigitalVoucher if plan.name(catalog) == "Sunday" => "Newspaper Digital Voucher - Observer"
case Product.Voucher if plan.name(catalog) == "Sunday" => "Newspaper Voucher - Observer"
case _ => plan.productName
}
}
class FilterPlans(subscription: Subscription, catalog: Catalog)(implicit val logPrefix: LogPrefix) extends SafeLogging {
private val sortedPlans = subscription.ratePlans
.filter(_.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
})
.sortBy(_.effectiveStartDate.toDate)
val currentPlans: List[RatePlan] = sortedPlans.filter(plan => !plan.effectiveStartDate.isAfter(now) && plan.effectiveEndDate.isAfter(now))
val futurePlans: List[RatePlan] = sortedPlans.filter(plan => plan.effectiveStartDate.isAfter(now))
val startDate: Option[LocalDate] = sortedPlans.headOption.map(_.effectiveStartDate)
val endDate: Option[LocalDate] = sortedPlans.headOption.map(_.effectiveEndDate)
if (currentPlans.length > 1) logger.warn(s"More than one 'current plan' on sub with id: ${subscription.id}")
}
object CancelledSubscription {
import AccountDetails._
def apply(subscription: Subscription, catalog: Catalog): JsObject = {
GetCurrentPlans
.bestCancelledPlan(subscription)
.map { plan =>
Json.obj(
"tier" -> getTier(catalog, plan),
"subscription" -> Json.obj(
"subscriptionId" -> subscription.subscriptionNumber.getNumber,
"cancellationEffectiveDate" -> subscription.termEndDate,
"start" -> subscription.customerAcceptanceDate,
"end" -> Seq(subscription.termEndDate, subscription.customerAcceptanceDate).max,
"readerType" -> subscription.readerType.value,
"accountId" -> subscription.accountId.get,
),
)
}
.getOrElse(Json.obj())
}
}