app/com/gu/memsub/promo/Promotion.scala (267 lines of code) (raw):
package com.gu.memsub.promo
import java.util.UUID
import com.github.nscala_time.time.Imports._
import com.gu.i18n.{Country, CountryGroup}
import com.gu.memsub.Product.Voucher
import com.gu.memsub.Subscription.ProductRatePlanId
import com.gu.memsub._
import com.gu.memsub.images.ResponsiveImageGroup
import com.gu.memsub.promo.CovariantIdObject.CovariantId
import org.joda.time.Days
import scala.language.higherKinds
import scalaz.\/
import scalaz.syntax.std.boolean._
case class Channel(get: String)
case class PromoCode(get: String) {
override def toString: String = get
}
object NormalisedPromoCode {
def safeFromString(str: String): PromoCode = PromoCode(str.filter(_.isLetterOrDigit).toUpperCase)
}
case class AppliesTo(productRatePlanIds: Set[ProductRatePlanId], countries: Set[Country])
case class CampaignCode(get: String) extends AnyVal
sealed trait CampaignGroup {
val id: String
}
object CampaignGroup {
case object SupporterPlus extends CampaignGroup {
override val id = "supporterPlus"
}
case object TierThree extends CampaignGroup {
override val id = "tierThree"
}
case object DigitalPack extends CampaignGroup {
override val id = "digitalpack"
}
case object Newspaper extends CampaignGroup {
override val id = "newspaper"
}
case object GuardianWeekly extends CampaignGroup {
override val id = "weekly"
}
def fromId(id: String): Option[CampaignGroup] = id match {
case DigitalPack.id => Some(DigitalPack)
case SupporterPlus.id => Some(SupporterPlus)
case TierThree.id => Some(TierThree)
case Newspaper.id => Some(Newspaper)
case GuardianWeekly.id => Some(GuardianWeekly)
case _ => None
}
}
case class Campaign(code: CampaignCode, group: CampaignGroup, name: String, sortDate: Option[DateTime])
object AppliesTo {
def ukOnly(prpIds: Set[ProductRatePlanId]) = AppliesTo(prpIds, Set(Country.UK))
def all(prpIds: Set[ProductRatePlanId]) = AppliesTo(prpIds, CountryGroup.countries.toSet)
}
sealed trait PromoError {
def msg: String
}
sealed trait PromoContext
sealed trait NewUsers extends PromoContext
sealed trait Upgrades extends PromoContext
sealed trait Renewal extends PromoContext
sealed trait Both extends NewUsers with Upgrades with Renewal
case class ValidPromotion[+C <: PromoContext](code: PromoCode, promotion: Promotion[PromotionType[C], Option, LandingPage]) {
val trackingCode:Option[PromoCode] = if (promotion.isTracking) Some(code) else None
val displayableCode:Option[PromoCode] = if (!promotion.isTracking) Some(code) else None
}
sealed trait PromotionType[+C <: PromoContext] {
override def toString = getClass.getSimpleName
val name: String
}
object PromotionType {
val incentive = "incentive"
val double = "double"
val percentDiscount = "percent_discount"
val freeTrial = "free_trial"
val tracking = "tracking"
val retention = "retention"
}
case class DoubleType[C <: PromoContext](a: PromotionType[C], b: PromotionType[C]) extends PromotionType[C] {
override val name: String = PromotionType.double
}
case class Incentive(redemptionInstructions: String, termsAndConditions: Option[String], legalTerms: Option[String]) extends PromotionType[Both] {
val name = PromotionType.incentive
}
case class PercentDiscount(durationMonths: Option[Int], amount: Double) extends PromotionType[Both] {
val name = PromotionType.percentDiscount
}
case class FreeTrial(duration: Days) extends PromotionType[NewUsers] {
val name = PromotionType.freeTrial
}
case object Tracking extends PromotionType[Both] {
val name = PromotionType.tracking
}
case object Retention extends PromotionType[Renewal] {
val name = PromotionType.retention
}
case object InvalidCountry extends PromoError {
override val msg = "The promo code you supplied is not applicable in this country"
}
case object InvalidProductRatePlan extends PromoError {
override val msg = "The promo code you supplied is not applicable for this product"
}
case object NotApplicable extends PromoError {
override val msg = "This promotion is not applicable"
}
case object NoSuchCode extends PromoError {
override val msg = "Unknown or expired promo code"
}
case object ExpiredPromotion extends PromoError {
override val msg = "The promo code you supplied has expired"
}
case object PromotionNotActiveYet extends PromoError {
override val msg = "The promo code you supplied is not active yet"
}
sealed trait SectionColour
case object Blue extends SectionColour
case object Grey extends SectionColour
case object White extends SectionColour
sealed trait LandingPage
sealed trait HeroImageAlignment
case object Top extends HeroImageAlignment
case object Bottom extends HeroImageAlignment
case object Centre extends HeroImageAlignment
case class HeroImage(image: ResponsiveImageGroup, alignment: HeroImageAlignment)
case class SupporterPlusLandingPage(
title: Option[String],
subtitle: Option[String],
description: Option[String],
roundelHtml: Option[String],
heroImage: Option[HeroImage],
image: Option[ResponsiveImageGroup]
) extends LandingPage
case class TierThreeLandingPage(
title: Option[String],
subtitle: Option[String],
description: Option[String],
roundelHtml: Option[String],
heroImage: Option[HeroImage],
image: Option[ResponsiveImageGroup]
) extends LandingPage
case class DigitalPackLandingPage(
title: Option[String],
description: Option[String],
roundelHtml: Option[String],
image: Option[ResponsiveImageGroup],
sectionColour: Option[SectionColour]
) extends LandingPage
case class NewspaperLandingPage(
title: Option[String],
description: Option[String],
defaultProduct: String = Voucher.name,
roundelHtml: Option[String],
) extends LandingPage
case class WeeklyLandingPage(
title: Option[String],
description: Option[String],
roundelHtml: Option[String],
image: Option[ResponsiveImageGroup],
sectionColour: Option[SectionColour]
) extends LandingPage
case class Promotion[+T <: PromotionType[PromoContext], M[+_], +P <: LandingPage](
uuid: UUID,
name: String,
description: String,
appliesTo: AppliesTo,
campaign: CampaignCode,
channelCodes: Map[Channel, Set[PromoCode]],
landingPage: M[P],
starts: DateTime,
expires: Option[DateTime],
promotionType: T) {
override def toString: String = {
val allCodes = channelCodes.values.flatten
s"$name codes:${allCodes.mkString(", ")} [$uuid]"
}
val isTracking = promotionType == Tracking
def codes: Seq[PromoCode] = channelCodes.flatMap { case (_, codes) => codes}.toSeq
private def toLegacyResponse(errors: Seq[PromoError]) = errors match {
case Nil => \/.r[PromoError](())
case errors => \/.l[Unit](errors.head)
}
def validateFor(prpId: ProductRatePlanId, country: Country, now: DateTime = DateTime.now()): PromoError \/ Unit =
toLegacyResponse(validateAll(Some(prpId), country, now))
def validate(country: Country, now: DateTime = DateTime.now()): PromoError \/ Unit = toLegacyResponse(validateAll(None, country, now))
def validateAll(prpId: Option[ProductRatePlanId] = None, country: Country, now: DateTime = DateTime.now()): Seq[PromoError] =
List(
prpId.find(pId => promotionType != Tracking && !appliesTo.productRatePlanIds.contains(pId)).map(_ => InvalidProductRatePlan),
(!appliesTo.countries.contains(country)).option(InvalidCountry),
starts.isAfter(now).option(PromotionNotActiveYet),
expires.find(e => e.isEqual(now) || e.isBefore(now)).map(_ => ExpiredPromotion)
).flatten
}
object CovariantIdObject {
type CovariantId[+A] = A
}
object Promotion {
type AnyPromotion = Promotion[PromotionType[PromoContext], Option, LandingPage]
type PromoWithSupporterPlusLandingPage = Promotion[PromotionType[PromoContext], CovariantId, SupporterPlusLandingPage]
type PromoWithDigitalPackLandingPage = Promotion[PromotionType[PromoContext], CovariantId, DigitalPackLandingPage]
type PromoWithNewspaperLandingPage = Promotion[PromotionType[PromoContext], CovariantId, NewspaperLandingPage]
type PromoWithWeeklyLandingPage = Promotion[PromotionType[PromoContext], CovariantId, WeeklyLandingPage]
def apply[T <: PromotionType[PromoContext]](
name: String,
description: String,
appliesTo: AppliesTo,
campaign: CampaignCode,
channelCodes: Map[Channel, Set[PromoCode]],
landingPage: Option[LandingPage],
starts: DateTime,
expires: Option[DateTime],
promotionType: T): Promotion[T, Option, LandingPage] = {
Promotion(
uuid = UUID.randomUUID(),
name = name,
description = description,
appliesTo = appliesTo,
campaign = campaign,
channelCodes = channelCodes,
landingPage = landingPage,
starts = starts,
expires = expires,
promotionType = promotionType
)
}
implicit class PromoTypeCasts[A[+_], I <: LandingPage](in: Promotion[PromotionType[PromoContext], A, I]) {
type PromoOpt[T <: PromotionType[PromoContext]] = Option[Promotion[T, A, I]]
private def findType[B](i: PromotionType[PromoContext], f: PartialFunction[PromotionType[PromoContext], B]): Option[B] = i match {
case DoubleType(a, b) => f.lift(a).orElse(f.lift(b))
case c => f.lift(c)
}
def asDiscount: PromoOpt[PercentDiscount] = findType(in.promotionType, { case e: PercentDiscount => in.copy(promotionType = e) })
def asFreeTrial: PromoOpt[FreeTrial] = findType(in.promotionType, { case e: FreeTrial => in.copy(promotionType = e) })
def asIncentive: PromoOpt[Incentive] = findType(in.promotionType, { case e: Incentive => in.copy(promotionType = e) })
def asTracking: PromoOpt[Tracking.type] = findType(in.promotionType, { case Tracking => in.copy(promotionType = Tracking) })
def asRetention: PromoOpt[Retention.type] = findType(in.promotionType, { case Retention => in.copy(promotionType = Retention) })
}
implicit class PromoLandingPageCasts[A <: PromotionType[PromoContext], M[+_]](in: Promotion[A, Option, LandingPage]) {
type PromoOpt[L <: LandingPage] = Option[Promotion[A, CovariantId, L]]
def asDigitalPack: PromoOpt[DigitalPackLandingPage] = in.landingPage.collect { case f: DigitalPackLandingPage => in.copy[A, CovariantId, DigitalPackLandingPage](landingPage = (f)) }
def asNewspaper: PromoOpt[NewspaperLandingPage] = in.landingPage.collect { case f: NewspaperLandingPage => in.copy[A, CovariantId, NewspaperLandingPage](landingPage = (f)) }
def asWeekly: PromoOpt[WeeklyLandingPage] = in.landingPage.collect { case f: WeeklyLandingPage => in.copy[A, CovariantId, WeeklyLandingPage](landingPage = (f)) }
def asSupporterPlus: PromoOpt[SupporterPlusLandingPage] = in.landingPage.collect { case f: SupporterPlusLandingPage => in.copy[A, CovariantId, SupporterPlusLandingPage](landingPage = (f)) }
def asTierThree: PromoOpt[TierThreeLandingPage] = in.landingPage.collect { case f: TierThreeLandingPage => in.copy[A, CovariantId, TierThreeLandingPage](landingPage = (f)) }
}
def asAnyPromotion[T <: PromotionType[PromoContext]](in: Promotion[T, CovariantId, LandingPage]): AnyPromotion =
in.copy(landingPage = Some(in.landingPage))
}
object PercentDiscount {
private implicit class MagnanimousDouble(d: Double) {
def roundGenerously(precision: Int) = BigDecimal(d).setScale(2, BigDecimal.RoundingMode.UP).toDouble
}
implicit class PriceApplicator[M[+_]](in: PercentDiscount) {
def applyDiscount(price: Price, bp: BillingPeriod): Price = {
val (discountPercent, _) = getDiscountScaledToPeriod(in, bp)
price.*(1f - (discountPercent.toFloat / 100f))
}
}
def getDiscountScaledToPeriod(percentDiscount: PercentDiscount, billingPeriod: BillingPeriod): (Double, Double) = {
val periodRatio = percentDiscount.durationMonths.fold(1.toDouble) { durationInMonths =>
durationInMonths.toDouble / billingPeriod.monthsInPeriod.toDouble
}
val numberOfNewPeriods = Math.ceil(periodRatio)
val newDiscountPercent = (percentDiscount.amount * periodRatio) / numberOfNewPeriods
(newDiscountPercent.roundGenerously(2), numberOfNewPeriods)
}
}