app/com/gu/memsub/subsv2/Plan.scala (477 lines of code) (raw):
package com.gu.memsub.subsv2
import com.gu.i18n.Currency
import Currency.GBP
import com.gu.memsub.Subscription.{
ProductId,
ProductRatePlanChargeId,
ProductRatePlanId,
RatePlanId,
SubscriptionRatePlanChargeId,
Feature => SubsFeature,
}
import scalaz.NonEmptyList
import com.gu.memsub._
import scalaz.syntax.semigroup._
import PricingSummary._
import org.joda.time.LocalDate
import play.api.libs.json._
import scala.language.higherKinds
import BillingPeriod._
import Benefit._
trait ZuoraEnum {
def id: String
}
object ZuoraEnum {
def getReads[T <: ZuoraEnum](allValues: Seq[T], errorMessage: String): Reads[T] = new Reads[T] {
override def reads(json: JsValue): JsResult[T] = json match {
case JsString(zuoraId) =>
allValues.find(_.id == zuoraId).map(JsSuccess(_)).getOrElse(JsError(s"$errorMessage: $zuoraId"))
case v => JsError(s"$errorMessage: $v")
}
}
}
sealed trait EndDateCondition extends ZuoraEnum
case object SubscriptionEnd extends EndDateCondition {
override val id = "Subscription_End"
}
case object FixedPeriod extends EndDateCondition {
override val id = "Fixed_Period"
}
case object SpecificEndDate extends EndDateCondition {
override val id = "Specific_End_Date"
}
case object OneTime extends EndDateCondition {
override val id = "One_Time"
}
object EndDateCondition {
val values = Seq(SubscriptionEnd, FixedPeriod, SpecificEndDate, OneTime)
implicit val reads: Reads[EndDateCondition] = ZuoraEnum.getReads(values, "invalid end date condition value")
}
sealed trait ZBillingPeriod extends ZuoraEnum
case object ZYear extends ZBillingPeriod {
override val id = "Annual"
}
case object ZMonth extends ZBillingPeriod {
override val id = "Month"
}
case object ZQuarter extends ZBillingPeriod {
override val id = "Quarter"
}
case object ZSemiAnnual extends ZBillingPeriod {
override val id = "Semi_Annual"
}
case object ZSpecificMonths extends ZBillingPeriod {
override val id = "Specific_Months"
}
case object ZWeek extends ZBillingPeriod {
override val id = "Week"
}
case object ZSpecificWeeks extends ZBillingPeriod {
override val id = "Specific_Weeks"
}
case object ZTwoYears extends ZBillingPeriod {
override val id = "Two_Years"
}
case object ZThreeYears extends ZBillingPeriod {
override val id = "Three_Years"
}
object ZBillingPeriod {
val values = Seq(ZYear, ZTwoYears, ZThreeYears, ZMonth, ZQuarter, ZSemiAnnual, ZSpecificMonths, ZWeek, ZSpecificWeeks)
implicit val reads: Reads[ZBillingPeriod] = ZuoraEnum.getReads(values, "invalid billing period value")
}
sealed trait UpToPeriodsType extends ZuoraEnum
case object BillingPeriods extends UpToPeriodsType {
override val id = "Billing_Periods"
}
case object Days extends UpToPeriodsType {
override val id = "Days"
}
case object Weeks extends UpToPeriodsType {
override val id = "Weeks"
}
case object Months extends UpToPeriodsType {
override val id = "Months"
}
case object Years extends UpToPeriodsType {
override val id = "Years"
}
object UpToPeriodsType {
val values = Seq(BillingPeriods, Days, Weeks, Months, Years)
implicit val reads: Reads[UpToPeriodsType] = ZuoraEnum.getReads(values, "invalid up to periods type value")
}
/** Low level model of a Zuora rate plan charge
*/
case class ZuoraCharge(
id: SubscriptionRatePlanChargeId,
productRatePlanChargeId: ProductRatePlanChargeId,
pricing: PricingSummary,
billingPeriod: Option[ZBillingPeriod],
specificBillingPeriod: Option[Int] = None,
model: String,
name: String,
`type`: String,
endDateCondition: EndDateCondition,
upToPeriods: Option[Int],
upToPeriodsType: Option[UpToPeriodsType],
)
object ZuoraCharge {
def apply(
productRatePlanChargeId: ProductRatePlanChargeId,
pricing: PricingSummary,
billingPeriod: Option[ZBillingPeriod],
specificBillingPeriod: Option[Int],
model: String,
name: String,
`type`: String,
endDateCondition: EndDateCondition,
upToPeriods: Option[Int],
upToPeriodsType: Option[UpToPeriodsType],
): ZuoraCharge = ZuoraCharge(
SubscriptionRatePlanChargeId(""),
productRatePlanChargeId,
pricing,
billingPeriod,
specificBillingPeriod,
model,
name,
`type`,
endDateCondition,
upToPeriods,
upToPeriodsType,
)
}
/** Low level model of a product rate plan, as it appears in the Zuora product catalog
*/
case class CatalogZuoraPlan(
id: ProductRatePlanId,
name: String,
description: String,
productId: ProductId,
saving: Option[String],
charges: List[ZuoraCharge],
benefits: Map[ProductRatePlanChargeId, Benefit],
status: Status,
frontendId: Option[FrontendId],
private val productTypeOption: Option[String],
) {
lazy val productType =
productTypeOption.getOrElse(throw new RuntimeException("Product type is undefined for plan: " + name))
}
sealed trait FrontendId {
def name: String
}
object FrontendId {
case object OneYear extends FrontendId { val name = "OneYear" }
case object ThreeMonths extends FrontendId { val name = "ThreeMonths" }
case object Monthly extends FrontendId { val name = "Monthly" }
case object Quarterly extends FrontendId { val name = "Quarterly" }
case object Yearly extends FrontendId { val name = "Yearly" }
case object Introductory extends FrontendId { val name = "Introductory" }
case object Free extends FrontendId { val name = "Free" }
case object SixWeeks extends FrontendId { val name = "SixWeeks" }
case object TierThreeMonthlyROW extends FrontendId { val name = "TierThreeMonthlyROW" }
case object TierThreeAnnualROW extends FrontendId { val name = "TierThreeAnnualROW" }
case object TierThreeAnnualDomestic extends FrontendId { val name = "TierThreeAnnualDomestic" }
case object TierThreeMonthlyDomestic extends FrontendId { val name = "TierThreeMonthlyDomestic" }
case object TierThreeMonthlyROWV2 extends FrontendId { val name = "TierThreeMonthlyROWV2" }
case object TierThreeAnnualROWV2 extends FrontendId { val name = "TierThreeAnnualROWV2" }
case object TierThreeAnnualDomesticV2 extends FrontendId { val name = "TierThreeAnnualDomesticV2" }
case object TierThreeMonthlyDomesticV2 extends FrontendId { val name = "TierThreeMonthlyDomesticV2" }
val all = List(
OneYear,
ThreeMonths,
Monthly,
Quarterly,
Yearly,
Introductory,
Free,
SixWeeks,
TierThreeMonthlyROW,
TierThreeAnnualROW,
TierThreeMonthlyDomestic,
TierThreeAnnualDomestic,
TierThreeMonthlyROWV2,
TierThreeAnnualROWV2,
TierThreeMonthlyDomesticV2,
TierThreeAnnualDomesticV2,
)
def get(jsonString: String): Option[FrontendId] =
all.find(_.name == jsonString)
}
object CatalogPlan {
type Contributor = CatalogPlan[Product.Contribution, PaidCharge[Contributor.type, Month.type], Current]
type Digipack[+B <: BillingPeriod] = CatalogPlan[Product.ZDigipack, PaidCharge[Digipack.type, B], Current]
type SupporterPlus[+B <: BillingPeriod] = CatalogPlan[Product.SupporterPlus, SupporterPlusCharges, Current]
type TierThree[+B <: BillingPeriod] = CatalogPlan[Product.TierThree, TierThreeCharges, Current]
type Delivery = CatalogPlan[Product.Delivery, PaperCharges, Current]
type NationalDelivery = CatalogPlan[Product.NationalDelivery, PaperCharges, Current]
type Voucher = CatalogPlan[Product.Voucher, PaperCharges, Current]
type DigitalVoucher = CatalogPlan[Product.DigitalVoucher, PaperCharges, Current]
type AnyPlan = CatalogPlan[Product, ChargeList, Current]
type Paid = CatalogPlan[Product, PaidChargeList, Current]
type Free = CatalogPlan[Product, FreeChargeList, Current]
type WeeklyZoneA[+B <: BillingPeriod] = CatalogPlan[Product.WeeklyZoneA, PaidCharge[Weekly.type, B], Current]
type WeeklyZoneB[+B <: BillingPeriod] = CatalogPlan[Product.WeeklyZoneB, PaidCharge[Weekly.type, B], Current]
type WeeklyZoneC[+B <: BillingPeriod] = CatalogPlan[Product.WeeklyZoneC, PaidCharge[Weekly.type, B], Current]
type WeeklyDomestic[+B <: BillingPeriod] = CatalogPlan[Product.WeeklyDomestic, PaidCharge[Weekly.type, B], Current]
type WeeklyRestOfWorld[+B <: BillingPeriod] =
CatalogPlan[Product.WeeklyRestOfWorld, PaidCharge[Weekly.type, B], Current]
type Paper = CatalogPlan[Product.Paper, PaidChargeList, Current]
type ContentSubscription = CatalogPlan[Product.ContentSubscription, PaidChargeList, Current]
type RecurringContentSubscription[+B <: BillingPeriod] =
CatalogPlan[Product.ContentSubscription, PaidCharge[Benefit, B], Current]
type RecurringPlan[+B <: BillingPeriod] = CatalogPlan[Product, PaidCharge[Benefit, B], Current]
}
case class PlansWithIntroductory[+B](plans: List[B], associations: List[(B, B)])
case class DigipackPlans(
month: CatalogPlan.Digipack[Month.type],
quarter: CatalogPlan.Digipack[Quarter.type],
year: CatalogPlan.Digipack[Year.type],
) {
lazy val plans = List(month, quarter, year)
}
case class SupporterPlusPlans(
month: CatalogPlan.SupporterPlus[Month.type],
year: CatalogPlan.SupporterPlus[Year.type],
) {
lazy val plans = List(
month,
year,
)
}
case class TierThreePlans(
domesticMonthy: CatalogPlan.TierThree[Month.type],
domesticAnnual: CatalogPlan.TierThree[Year.type],
restOfWorldMonthy: CatalogPlan.TierThree[Month.type],
restOfWorldAnnual: CatalogPlan.TierThree[Year.type],
domesticMonthyV2: CatalogPlan.TierThree[Month.type],
domesticAnnualV2: CatalogPlan.TierThree[Year.type],
restOfWorldMonthyV2: CatalogPlan.TierThree[Month.type],
restOfWorldAnnualV2: CatalogPlan.TierThree[Year.type],
) {
lazy val plans = List(
domesticMonthy,
domesticAnnual,
restOfWorldMonthy,
restOfWorldAnnual,
domesticMonthyV2,
domesticAnnualV2,
restOfWorldMonthyV2,
restOfWorldAnnualV2,
)
}
case class WeeklyZoneBPlans(
quarter: CatalogPlan.WeeklyZoneB[Quarter.type],
year: CatalogPlan.WeeklyZoneB[Year.type],
oneYear: CatalogPlan.WeeklyZoneB[OneYear.type],
) {
lazy val plans = List(quarter, year, oneYear)
val plansWithAssociations = PlansWithIntroductory(plans, List.empty)
}
case class WeeklyZoneAPlans(
sixWeeks: CatalogPlan.WeeklyZoneA[SixWeeks.type],
quarter: CatalogPlan.WeeklyZoneA[Quarter.type],
year: CatalogPlan.WeeklyZoneA[Year.type],
oneYear: CatalogPlan.WeeklyZoneA[OneYear.type],
) {
val plans = List(sixWeeks, quarter, year, oneYear)
val associations = List(sixWeeks -> quarter)
val plansWithAssociations = PlansWithIntroductory(plans, associations)
}
case class WeeklyZoneCPlans(
sixWeeks: CatalogPlan.WeeklyZoneC[SixWeeks.type],
quarter: CatalogPlan.WeeklyZoneC[Quarter.type],
year: CatalogPlan.WeeklyZoneC[Year.type],
) {
lazy val plans = List(sixWeeks, quarter, year)
val associations = List(sixWeeks -> quarter)
val plansWithAssociations = PlansWithIntroductory(plans, associations)
}
case class WeeklyDomesticPlans(
sixWeeks: CatalogPlan.WeeklyDomestic[SixWeeks.type],
quarter: CatalogPlan.WeeklyDomestic[Quarter.type],
year: CatalogPlan.WeeklyDomestic[Year.type],
month: CatalogPlan.WeeklyDomestic[Month.type],
oneYear: CatalogPlan.WeeklyDomestic[OneYear.type],
threeMonths: CatalogPlan.WeeklyDomestic[ThreeMonths.type],
) {
lazy val plans = List(sixWeeks, quarter, year, month, oneYear, threeMonths)
val associations = List(sixWeeks -> quarter)
val plansWithAssociations = PlansWithIntroductory(plans, associations)
}
case class WeeklyRestOfWorldPlans(
sixWeeks: CatalogPlan.WeeklyRestOfWorld[SixWeeks.type],
quarter: CatalogPlan.WeeklyRestOfWorld[Quarter.type],
year: CatalogPlan.WeeklyRestOfWorld[Year.type],
month: CatalogPlan.WeeklyRestOfWorld[Month.type],
oneYear: CatalogPlan.WeeklyRestOfWorld[OneYear.type],
threeMonths: CatalogPlan.WeeklyRestOfWorld[ThreeMonths.type],
) {
lazy val plans = List(sixWeeks, quarter, year, month, oneYear, threeMonths)
val associations = List(sixWeeks -> quarter)
val plansWithAssociations = PlansWithIntroductory(plans, associations)
}
case class WeeklyPlans(
zoneA: WeeklyZoneAPlans,
zoneB: WeeklyZoneBPlans,
zoneC: WeeklyZoneCPlans,
domestic: WeeklyDomesticPlans,
restOfWorld: WeeklyRestOfWorldPlans,
) {
val plans = List(zoneA.plans, zoneB.plans, zoneC.plans, domestic.plans, restOfWorld.plans)
}
case class Catalog(
digipack: DigipackPlans,
supporterPlus: SupporterPlusPlans,
tierThree: TierThreePlans,
contributor: CatalogPlan.Contributor,
voucher: NonEmptyList[CatalogPlan.Voucher],
digitalVoucher: NonEmptyList[CatalogPlan.DigitalVoucher],
delivery: NonEmptyList[CatalogPlan.Delivery],
nationalDelivery: NonEmptyList[CatalogPlan.NationalDelivery],
weekly: WeeklyPlans,
map: Map[ProductRatePlanId, CatalogZuoraPlan],
) {
lazy val productMap: Map[ProductRatePlanChargeId, Benefit] =
map.values.flatMap(p => p.benefits).toMap
lazy val paid: Seq[CatalogPlan.Paid] = allSubs.flatten
lazy val allSubs: List[List[CatalogPlan.Paid]] =
List(
digipack.plans,
supporterPlus.plans,
tierThree.plans,
voucher.list.toList,
digitalVoucher.list.toList,
delivery.list.toList,
nationalDelivery.list.toList,
) ++ weekly.plans
}
/** A higher level representation of a number of Zuora rate plan charges
*/
sealed trait ChargeList {
def benefits: NonEmptyList[Benefit]
def currencies: Set[Currency]
}
sealed trait FreeChargeList extends ChargeList {
def currencies: Set[Currency]
}
sealed trait PaidChargeList extends ChargeList {
def gbpPrice = price.getPrice(GBP).getOrElse(throw new Exception("No GBP price"))
def currencies = price.currencies
def billingPeriod: BillingPeriod
def price: PricingSummary
def subRatePlanChargeId: SubscriptionRatePlanChargeId
}
/** Generic version of single free / paid charge This is to allow exhaustive matches on tier in membership
* i.e. the common ancestor type of Friend / Supporter will be Plan[ChargeList with SingleBenefit[MemberTier]] as
* opposed to just Plan[ChargeList] which isn't typed to only contain member tiers
*/
sealed trait SingleBenefit[+B <: Benefit] {
def benefit: B
}
/** So this is a charge "list" that must contain exactly one free charge like if you're a friend on membership
*/
case class FreeCharge[+B <: Benefit](benefit: B, currencies: Set[Currency])
extends FreeChargeList
with SingleBenefit[B] {
def benefits = NonEmptyList(benefit)
}
/** Same as above but we must have exactly one paid charge, giving us exactly one benefit This is used for supporter,
* partner, patron and digital pack subs
*/
case class PaidCharge[+B <: Benefit, +BP <: BillingPeriod](
benefit: B,
billingPeriod: BP,
price: PricingSummary,
chargeId: ProductRatePlanChargeId,
subRatePlanChargeId: SubscriptionRatePlanChargeId,
) extends PaidChargeList
with SingleBenefit[B] {
def benefits = NonEmptyList(benefit)
}
/** Paper plans will have lots of rate plan charges, but the general structure of them is that they'll give you the
* paper on a bunch of days, and if you're on a plus plan you'll have a digipack
*/
case class PaperCharges(dayPrices: Map[PaperDay, PricingSummary], digipack: Option[PricingSummary])
extends PaidChargeList {
def benefits =
NonEmptyList.fromSeq[Benefit](dayPrices.keys.head, dayPrices.keys.tail.toSeq ++ digipack.map(_ => Digipack))
def price: PricingSummary = (dayPrices.values.toSeq ++ digipack.toSeq).reduce(_ |+| _)
override def billingPeriod: BillingPeriod = BillingPeriod.Month
def chargedDays =
dayPrices.filterNot(_._2.isFree).keySet // Non-subscribed-to papers are priced as Zero on multi-day subs
val subRatePlanChargeId = SubscriptionRatePlanChargeId("")
}
/** Supporter Plus V2 has two rate plan charges, one for the subscription element and one for the additional
* contribution.
*/
case class SupporterPlusCharges(billingPeriod: BillingPeriod, pricingSummaries: List[PricingSummary])
extends PaidChargeList {
val subRatePlanChargeId = SubscriptionRatePlanChargeId("")
override def price: PricingSummary = pricingSummaries.reduce(_ |+| _)
override def benefits: NonEmptyList[Benefit] = NonEmptyList(SupporterPlus)
}
/** Tier Three
*/
case class TierThreeCharges(billingPeriod: BillingPeriod, pricingSummaries: List[PricingSummary])
extends PaidChargeList {
val subRatePlanChargeId = SubscriptionRatePlanChargeId("")
override def price: PricingSummary = pricingSummaries.reduce(_ |+| _)
override def benefits: NonEmptyList[Benefit] = NonEmptyList(TierThree)
}
/** This is the higher level model of a zuora rate plan, This particular trait is stuff common to both catalog and
* subscription plans
*/
sealed trait Plan[+P <: Product, +C <: ChargeList] {
def name: String
def description: String
def charges: C
def product: P
}
// a plan as it appears on a zuora subscription, as opposed to in the product catalog
sealed trait SubscriptionPlan[+P <: Product, +C <: ChargeList] extends Plan[P, C] {
def productRatePlanId: ProductRatePlanId
def productName: String
def productType: String
override def product: P
def isPaid: Boolean
def id: RatePlanId
def charges: C
def start: LocalDate
def end: LocalDate
}
/** we split subscription plans as to whether they're paid or free because some fields are specific to paid plans - i.e.
* charged through
*/
case class PaidSubscriptionPlan[+P <: Product, +C <: PaidChargeList](
id: RatePlanId,
productRatePlanId: ProductRatePlanId,
name: String,
description: String,
productName: String,
productType: String,
product: P,
features: List[SubsFeature],
charges: C,
chargedThrough: Option[LocalDate], // this is None if the sub hasn't been billed yet (on a free trial)
start: LocalDate,
end: LocalDate,
) extends SubscriptionPlan[P, C] { // or if you have been billed it is the date at which you'll next be billed
val isPaid = true
}
case class FreeSubscriptionPlan[+P <: Product, +C <: FreeChargeList](
id: RatePlanId,
productRatePlanId: ProductRatePlanId,
name: String,
description: String,
productName: String,
productType: String,
product: P,
charges: C,
start: LocalDate,
end: LocalDate,
) extends SubscriptionPlan[P, C] {
val isPaid = false
}
/** So this is the higher level model of a zuora product rate plan we don't need to split into paid / free catalog plans
* as the fields are the same
*/
case class CatalogPlan[+P <: Product, +C <: ChargeList, +S <: Status](
id: ProductRatePlanId,
product: P,
name: String,
description: String,
saving: Option[Int],
charges: C,
s: S,
) extends Plan[P, C] {
lazy val slug: String =
s"${product.name}-$name".replace(" ", "").toLowerCase
}
/** So the benefit of all these type parameters on the higher level models is that you can uniquely identify a
* particular plan by its type signature and if you can do that then you can pass your super specific (or more generic)
* plan into subscription service to find the subscription of your dreams
*/
object SubscriptionPlan {
type AnyPlan = SubscriptionPlan[Product, ChargeList]
type Paid = PaidSubscriptionPlan[Product, PaidChargeList]
type Free = FreeSubscriptionPlan[Product, FreeChargeList]
type ContentSubscription = PaidSubscriptionPlan[Product.ContentSubscription, PaidChargeList]
type Digipack = PaidSubscriptionPlan[Product.ZDigipack, PaidCharge[Benefit.Digipack.type, BillingPeriod]]
type SupporterPlus =
PaidSubscriptionPlan[Product.SupporterPlus, PaidCharge[Benefit.SupporterPlus.type, BillingPeriod]]
type TierThree = PaidSubscriptionPlan[Product.TierThree, PaidCharge[Benefit.TierThree.type, BillingPeriod]]
type Delivery = PaidSubscriptionPlan[Product.Delivery, PaperCharges]
type NationalDelivery = PaidSubscriptionPlan[Product.NationalDelivery, PaperCharges]
type Voucher = PaidSubscriptionPlan[Product.Voucher, PaperCharges]
type DigitalVoucher = PaidSubscriptionPlan[Product.DigitalVoucher, PaperCharges]
type DailyPaper = PaidSubscriptionPlan[Product.Paper, PaperCharges]
type PaperPlan = PaidSubscriptionPlan[Product.Paper, PaidChargeList]
type WeeklyZoneA = PaidSubscriptionPlan[Product.WeeklyZoneA, PaidCharge[Weekly.type, BillingPeriod]]
type WeeklyZoneB = PaidSubscriptionPlan[Product.WeeklyZoneB, PaidCharge[Weekly.type, BillingPeriod]]
type WeeklyPlan = PaidSubscriptionPlan[Product.Weekly, PaidCharge[Weekly.type, BillingPeriod]]
type Contributor = PaidSubscriptionPlan[Product.Contribution, PaidCharge[Benefit.Contributor.type, BillingPeriod]]
}