app/com/gu/memsub/subsv2/Subscription.scala (193 lines of code) (raw):
package com.gu.memsub.subsv2
import com.github.nscala_time.time.Imports._
import com.gu.memsub
import com.gu.memsub.Benefit._
import com.gu.memsub.BillingPeriod.{OneTimeChargeBillingPeriod, OneYear, ThreeMonths}
import com.gu.memsub.promo.PromoCode
import com.gu.memsub.subsv2.SubscriptionPlan._
import com.gu.memsub.{BillingPeriod, Product}
import org.joda.time.{DateTime, LocalDate}
import scala.language.higherKinds
import scala.reflect.ClassTag
import scalaz.syntax.all._
import scalaz.{-\/, NonEmptyList, Validation, \/, \/-}
case class CovariantNonEmptyList[+T](head: T, tail: List[T]) {
val list = head :: tail
}
case class Subscription[+P <: SubscriptionPlan.AnyPlan](
id: memsub.Subscription.Id,
name: memsub.Subscription.Name,
accountId: memsub.Subscription.AccountId,
startDate: LocalDate,
acceptanceDate: LocalDate,
termStartDate: LocalDate,
termEndDate: LocalDate,
casActivationDate: Option[DateTime],
promoCode: Option[PromoCode],
isCancelled: Boolean,
hasPendingFreePlan: Boolean,
plans: CovariantNonEmptyList[P],
readerType: ReaderType,
gifteeIdentityId: Option[String],
autoRenew: Boolean
) {
private def isPaid[P <: SubscriptionPlan.AnyPlan](plan: P) = plan.charges match {
case _: PaidChargeList => true
case _ => false
}
val firstPaymentDate = {
val paidPlans = plans.list.filter(isPaid)
(acceptanceDate :: paidPlans.map(_.start)).toList.min
}
lazy val plan: P = {
GetCurrentPlans(this, LocalDate.now).fold(error => throw new RuntimeException(error), _.head)
}
// can we make it optional to specify A and B?
def as[A <: Product, B <: ChargeList, SP <: SubscriptionPlan[A,B]](implicit a: ClassTag[A], b: ClassTag[B]): Option[Subscription[SP]] = {
if (a.runtimeClass.isInstance(plans.head.product) && b.runtimeClass.isInstance(plans.head.charges))
Some(asInstanceOf[Subscription[SP]])
else
None
}
def asDelivery = as[Product.Delivery, PaperCharges, SubscriptionPlan.Delivery]
def asNationalDelivery = as[Product.NationalDelivery, PaperCharges, SubscriptionPlan.NationalDelivery]
def asVoucher = as[Product.Voucher, PaperCharges, SubscriptionPlan.Voucher]
def asWeekly = as[Product.Weekly, PaidCharge[Weekly.type, BillingPeriod]/* TODO should check the benefit and billing period*/, SubscriptionPlan.WeeklyPlan]
def asDigipack = as[Product.ZDigipack, PaidChargeList, SubscriptionPlan.Digipack]
def asContribution = as[Product.Contribution, PaidChargeList, SubscriptionPlan.Contributor]
}
/*
this goes through all the plan objects and only returns the ones that are current.
This could include more than one of the same type especially if it's the changeover date.
It then sorts them so the "best" one is first in the list. Best just means more expensive,
so this code could be an area of disaster in future.
If we are comparing two Free plans (possible because there is a legacy Friend plan in Zuora) we need to work
with the newest plan for upgrade and cancel scenarios, so in this case the most recent start date wins.
*/
object GetCurrentPlans {
/*- negative if x < y
* - positive if x > y
* - zero otherwise (if x == y)*/
val planGoodnessOrder = new scala.Ordering[SubscriptionPlan.AnyPlan] {
val lt = -1; val eq = 0; val gt = 1
override def compare(x: AnyPlan, y: AnyPlan): Int = {
(x, y) match {
case (_: PaidSubscriptionPlan[_, _], _: FreeSubscriptionPlan[_, _]) => gt
case (_: FreeSubscriptionPlan[_, _], _: PaidSubscriptionPlan[_, _]) => lt
case (friendX: FreeSubscriptionPlan[_, _], friendY: FreeSubscriptionPlan[_, _]) => {
if (friendX.start < friendY.start) lt
else if (friendX.start > friendY.start) gt
else eq
}
case (planX: PaidSubscriptionPlan[_, _], planY: PaidSubscriptionPlan[_, _]) => {
val priceX = planX.charges.price.prices.head.amount
val priceY = planY.charges.price.prices.head.amount
(priceX * 100).toInt - (priceY * 100).toInt
}
}
}
}
def bestCancelledPlan[P <: SubscriptionPlan.AnyPlan](sub: Subscription[P]): Option[P] =
if (sub.isCancelled && sub.termEndDate.isBefore(LocalDate.now()))
sub.plans.list.sorted(planGoodnessOrder).reverse.headOption
else None
case class DiscardedPlan[+P <: SubscriptionPlan.AnyPlan](plan: P, why: String)
def apply[P <: SubscriptionPlan.AnyPlan](sub: Subscription[P], date: LocalDate): String \/ NonEmptyList[P] = {
val currentPlans = sub.plans.list.toList.sorted(planGoodnessOrder).reverse.map { plan =>
//If the sub hasn't been paid yet but has started we should fast-forward to the date of first payment (free trial)
val dateToCheck = if(sub.startDate <= date && sub.acceptanceDate > date) sub.acceptanceDate else date
val unvalidated = Validation.s[NonEmptyList[DiscardedPlan[P]]](plan)
/*
Note that a Contributor may have future sub.acceptanceDate and plan.startDate values if the user has
updated their payment amount via MMA since starting the contribution. In this case the alreadyStarted assessment
just checks that the sub.startDate is before, or the same as, the date received by this function.
*/
val ensureStarted = unvalidated.ensure(DiscardedPlan(plan, s"hasn't started as of $dateToCheck").wrapNel)(_)
val alreadyStarted = plan match {
case _: Contributor => ensureStarted(_ => sub.startDate <= date)
case _ => ensureStarted(_.start <= dateToCheck)
}
val freePlanCancelled = alreadyStarted.ensure(DiscardedPlan(plan, "has a free plan which has been cancelled").wrapNel)(_)
val contributorPlanCancelled = alreadyStarted.ensure(DiscardedPlan(plan, "has a contributor plan which has been cancelled").wrapNel)(_)
val paidPlanEnded = alreadyStarted.ensure(DiscardedPlan(plan, "has a paid plan which has ended").wrapNel)(_)
val digipackGiftEnded = alreadyStarted.ensure(DiscardedPlan(plan, "has a digipack gift plan which has ended").wrapNel)(_)
plan match {
case _: FreeSubscriptionPlan[_, _] => freePlanCancelled(_ => !sub.isCancelled)
case plan: PaidSubscriptionPlan[_, _] if plan.product == Product.Contribution => contributorPlanCancelled(_ => !sub.isCancelled)
case plan: PaidSubscriptionPlan[_, _] if plan.product == Product.Digipack && plan.charges.billingPeriod == OneTimeChargeBillingPeriod =>
digipackGiftEnded(_ => sub.termEndDate >= dateToCheck)
case plan: PaidSubscriptionPlan[_, _] => paidPlanEnded(_ => plan.end >= dateToCheck)
}
}
Sequence(currentPlans.map(_.leftMap(_.map(discard => s"Discarded ${discard.plan.id.get} because it ${discard.why}").list.toList.mkString("\n")).disjunction))
}
}
object Subscription {
def partial[P <: SubscriptionPlan.AnyPlan](hasPendingFreePlan: Boolean)(
id: memsub.Subscription.Id,
name: memsub.Subscription.Name,
accountId: memsub.Subscription.AccountId,
startDate: LocalDate,
acceptanceDate: LocalDate,
termStartDate: LocalDate,
termEndDate: LocalDate,
casActivationDate: Option[DateTime],
promoCode: Option[PromoCode],
isCancelled: Boolean,
readerType: ReaderType,
gifteeIdentityId: Option[String],
autoRenew: Boolean
)(plans: NonEmptyList[P]): Subscription[P] =
new Subscription(
id = id,
name = name,
accountId = accountId,
startDate = startDate,
acceptanceDate = acceptanceDate,
termStartDate = termStartDate,
termEndDate = termEndDate,
casActivationDate = casActivationDate,
promoCode = promoCode,
isCancelled = isCancelled,
hasPendingFreePlan = hasPendingFreePlan,
plans = CovariantNonEmptyList(plans.head, plans.tail.toList),
readerType = readerType,
gifteeIdentityId = gifteeIdentityId,
autoRenew = autoRenew
)
}
object ReaderType {
case object Direct extends ReaderType {
val value = "Direct"
}
case object Gift extends ReaderType {
val value = "Gift"
}
case object Agent extends ReaderType {
val value = "Agent"
}
case object Student extends ReaderType {
val value = "Student"
}
case object Complementary extends ReaderType {
val value = "Complementary" //Spelled this way to match value in Saleforce/Zuora
val alternateSpelling = "Complimentary"
}
case object Corporate extends ReaderType {
val value = "Corporate"
}
case object Patron extends ReaderType {
val value = "Patron"
}
def apply(maybeString: Option[String]): ReaderType =
maybeString.map {
case Direct.value => Direct
case Gift.value => Gift
case Agent.value => Agent
case Student.value => Student
case Complementary.value => Complementary
case Complementary.alternateSpelling => Complementary
case Corporate.value => Corporate
case Patron.value => Patron
case unknown => throw new RuntimeException(s"Unknown reader type: $unknown")
}.getOrElse(Direct)
}
sealed trait ReaderType {
def value: String
}
object Sequence {
def apply[A](eitherList: List[String \/ A]): String \/ NonEmptyList[A] = {
val zero = (List[String](), List[A]())
val product = eitherList.foldRight(zero)({
case (-\/(left), (accuLeft, accuRight)) => (left :: accuLeft, accuRight)
case (\/-(right), (accuLeft, accuRight)) => (accuLeft, right :: accuRight)
})
// if any are right, return them all, otherwise return all the left
product match {
case (Nil, Nil) => -\/("no subscriptions found at all, even invalid ones") // no failures or successes
case (errors, Nil) => -\/(errors.mkString("\n")) // no successes
case (_, result :: results) => \/-(NonEmptyList.fromSeq(result, results)) // discard some errors as long as some worked (log it?)
}
}
}