Skip to content

Commit

Permalink
Merge pull request #964 from guardian/ph-20231216-1541-newspaper-migr…
Browse files Browse the repository at this point in the history
…ation

Newspaper2024
  • Loading branch information
shtukas authored Jan 28, 2024
2 parents fd2974e + 22a0095 commit 555cc7e
Show file tree
Hide file tree
Showing 58 changed files with 166,591 additions and 67 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import pricemigrationengine.model._
import pricemigrationengine.migrations._
import pricemigrationengine.services._
import zio.{Clock, ZIO}

import java.time.{LocalDate, LocalDateTime, ZoneOffset}
import pricemigrationengine.migrations.newspaper2024Migration

/** Carries out price-rise amendments in Zuora.
*/
Expand All @@ -16,7 +16,7 @@ object AmendmentHandler extends CohortHandler {
// TODO: move to config
private val batchSize = 100

private def main(cohortSpec: CohortSpec): ZIO[Logging with CohortTable with Zuora, Failure, HandlerOutput] =
private def main(cohortSpec: CohortSpec): ZIO[Logging with CohortTable with Zuora, Failure, HandlerOutput] = {
for {
catalogue <- Zuora.fetchProductCatalogue
count <- CohortTable
Expand All @@ -25,6 +25,7 @@ object AmendmentHandler extends CohortHandler {
.mapZIO(item => amend(cohortSpec, catalogue, item).tapBoth(Logging.logFailure(item), Logging.logSuccess(item)))
.runCount
} yield HandlerOutput(isComplete = count < batchSize)
}

private def amend(
cohortSpec: CohortSpec,
Expand Down Expand Up @@ -208,6 +209,13 @@ object AmendmentHandler extends CohortHandler {
startDate,
)
)
case Newspaper2024 =>
ZIO.fromEither(
newspaper2024Migration.Amendment.subscriptionToZuoraSubscriptionUpdate(
subscriptionBeforeUpdate,
startDate,
)
)
case Legacy =>
ZIO.fromEither(
ZuoraSubscriptionUpdate
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
package pricemigrationengine.handlers

import pricemigrationengine.migrations.newspaper2024Migration
import pricemigrationengine.model.CohortTableFilter._
import pricemigrationengine.model.{CohortSpec, _}
import pricemigrationengine.model._
import pricemigrationengine.services._
import zio.{Clock, IO, Random, ZIO}

import pricemigrationengine.util.Date
import java.time.LocalDate

/** Calculates start date and new price for a set of CohortItems.
Expand Down Expand Up @@ -95,10 +96,20 @@ object EstimationHandler extends CohortHandler {
} yield result
}

def datesMax(date1: LocalDate, date2: LocalDate): LocalDate = if (date1.isBefore(date2)) date2 else date1

def startDateGeneralLowerbound(cohortSpec: CohortSpec, today: LocalDate): LocalDate = {
datesMax(
def startDateGeneralLowerBound(
cohortSpec: CohortSpec,
today: LocalDate
): LocalDate = {
// The startDateGeneralLowerBound is a function of the cohort spec and the notification min time.
// The cohort spec carries the lowest date we specify there can be a price migration, and the notification min
// time ensures the legally required lead time for customer communication. The max of those two dates is the date
// from which we can realistically perform a price increase. With that said, other policies can apply, for
// instance:
// - The one year policy, which demand that we do not price rise customers during the subscription first year
// - The spread: a mechanism, used for monthlies, by which we do not let a large number of monthlies migrate
// during a single month.

Date.datesMax(
cohortSpec.earliestPriceMigrationStartDate,
today.plusDays(
NotificationHandler.minLeadTime(cohortSpec: CohortSpec) + 1
Expand All @@ -110,7 +121,7 @@ object EstimationHandler extends CohortHandler {
// and the customer's customerAcceptanceDate. Doing so we implement the policy of not increasing customers during
// their first year.
def oneYearPolicy(lowerbound: LocalDate, subscription: ZuoraSubscription): LocalDate = {
datesMax(lowerbound, subscription.customerAcceptanceDate.plusMonths(12))
Date.datesMax(lowerbound, subscription.customerAcceptanceDate.plusMonths(12))
}

// Determines whether the subscription is a monthly subscription
Expand All @@ -123,9 +134,8 @@ object EstimationHandler extends CohortHandler {
.contains("Month")
}

// In legacy print product cases, we have spread the price rises over 3 months for monthly subscriptions, but
// in the case of membership we want to do this over a single month, hence a value of 1.
// For annual subscriptions we are not applying any spread and defaulting to value 1
// In legacy print product cases, we have spread the price rises over 3 months for monthly subscriptions, this is
// the default behaviour. For annual subscriptions we are not applying any spread and defaulting to value 1.
def decideSpreadPeriod(
subscription: ZuoraSubscription,
invoicePreview: ZuoraInvoiceList,
Expand All @@ -135,6 +145,7 @@ object EstimationHandler extends CohortHandler {
MigrationType(cohortSpec) match {
case Membership2023Monthlies => 1
case Membership2023Annuals => 1
case Newspaper2024 => newspaper2024Migration.Estimation.startDateSpreadPeriod(subscription)
case _ => 3
}
} else 1
Expand All @@ -146,14 +157,26 @@ object EstimationHandler extends CohortHandler {
cohortSpec: CohortSpec,
today: LocalDate
): IO[ConfigFailure, LocalDate] = {

// We start by deciding the start date general lower bound, which is determined by the cohort's
// earliestPriceMigrationStartDate and the notification period to this migration
val startDateLowerBound1 = startDateGeneralLowerbound(cohortSpec: CohortSpec, today: LocalDate)
// earliestPriceMigrationStartDate and the notification period to this migration. See comment in
// the body of startDateGeneralLowerbound for details.

// Note that for Newspaper2024 we use that migration own version of this function (due to the unusual scheduling
// nature of that migration), which also takes the subscription.

val startDateLowerBound1 = MigrationType(cohortSpec) match {
case Newspaper2024 =>
newspaper2024Migration.Estimation.startDateGeneralLowerbound(cohortSpec, today, subscription)
case _ => startDateGeneralLowerBound(cohortSpec, today)
}

// We now respect the policy of not increasing members during their first year
val startDateLowerBound2 = oneYearPolicy(startDateLowerBound1, subscription)

// Looking up the spread period for this migration
val spreadPeriod = decideSpreadPeriod(subscription, invoicePreview, cohortSpec)

for {
randomFactor <- Random.nextIntBetween(0, spreadPeriod)
} yield startDateLowerBound2.plusMonths(randomFactor)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import pricemigrationengine.model.membershipworkflow._
import pricemigrationengine.services._
import zio.{Clock, ZIO}
import com.gu.i18n
import pricemigrationengine.migrations.newspaper2024Migration

import java.time.LocalDate
import java.time.format.DateTimeFormatter
Expand All @@ -24,30 +25,28 @@ object NotificationHandler extends CohortHandler {
// of 30 days.

val letterMaxNotificationLeadTime = 49
private val letterMinNotificationLeadTime = 35

// Membership migration
// Notification period: -33 (included) to -31 (excluded) days
private val emailMaxNotificationLeadTime = 33
private val emailMinNotificationLeadTime = 31
// The digital migrations' notification window is from -33 (included) to -31 (excluded)

def maxLeadTime(cohortSpec: CohortSpec): Int = {
MigrationType(cohortSpec) match {
case Membership2023Monthlies => emailMaxNotificationLeadTime
case Membership2023Annuals => emailMaxNotificationLeadTime
case SupporterPlus2023V1V2MA => emailMaxNotificationLeadTime
case DigiSubs2023 => emailMaxNotificationLeadTime
case Legacy => letterMaxNotificationLeadTime
case Membership2023Monthlies => 33
case Membership2023Annuals => 33
case SupporterPlus2023V1V2MA => 33
case DigiSubs2023 => 33
case Newspaper2024 => newspaper2024Migration.StaticData.maxLeadTime
case Legacy => 49
}
}

def minLeadTime(cohortSpec: CohortSpec): Int = {
MigrationType(cohortSpec) match {
case Membership2023Monthlies => emailMinNotificationLeadTime
case Membership2023Annuals => emailMinNotificationLeadTime
case SupporterPlus2023V1V2MA => emailMinNotificationLeadTime
case DigiSubs2023 => emailMinNotificationLeadTime
case Legacy => letterMinNotificationLeadTime
case Membership2023Monthlies => 31
case Membership2023Annuals => 31
case SupporterPlus2023V1V2MA => 31
case DigiSubs2023 => 31
case Newspaper2024 => newspaper2024Migration.StaticData.minLeadTime
case Legacy => 35
}
}

Expand Down Expand Up @@ -156,6 +155,7 @@ object NotificationHandler extends CohortHandler {
case Membership2023Annuals => true
case SupporterPlus2023V1V2MA => true
case DigiSubs2023 => true
case Newspaper2024 => true
case Legacy => false
}
cappedEstimatedNewPriceWithCurrencySymbol = s"${currencySymbol}${PriceCap(oldPrice, estimatedNewPrice, forceEstimated)}"
Expand Down Expand Up @@ -253,14 +253,19 @@ object NotificationHandler extends CohortHandler {
cohortSpec: CohortSpec,
address: SalesforceAddress
): Either[NotificationHandlerFailure, String] = {
// The country is usually a required field, this came from the old print migrations. It was
// not required for the 2023 digital migrations. Although technically required for
// the 2024 print migration, "United Kingdom" can be substituted for missing values considering
// that we are only delivery in the UK.
MigrationType(cohortSpec) match {
case Membership2023Monthlies =>
requiredField(address.country.fold(Some("United Kingdom"))(Some(_)), "Contact.OtherAddress.country")
case Membership2023Annuals =>
requiredField(address.country.fold(Some("United Kingdom"))(Some(_)), "Contact.OtherAddress.country")
case SupporterPlus2023V1V2MA =>
requiredField(address.country.fold(Some("United Kingdom"))(Some(_)), "Contact.OtherAddress.country")
case _ => requiredField(address.country, "Contact.OtherAddress.country")
case Newspaper2024 => Right(address.country.getOrElse("United Kingdom"))
case _ => requiredField(address.country, "Contact.OtherAddress.country")
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ object SalesforcePriceRiseCreationHandler extends CohortHandler {
case Membership2023Annuals => true
case SupporterPlus2023V1V2MA => true
case DigiSubs2023 => true
case Newspaper2024 => true
case Legacy => false
}
SalesforcePriceRise(
Expand All @@ -99,7 +100,7 @@ object SalesforcePriceRiseCreationHandler extends CohortHandler {
}
}

def handle(input: CohortSpec): ZIO[Logging, Failure, HandlerOutput] =
def handle(input: CohortSpec): ZIO[Logging, Failure, HandlerOutput] = {
main(input).provideSome[Logging](
EnvConfig.cohortTable.layer,
EnvConfig.salesforce.layer,
Expand All @@ -109,4 +110,5 @@ object SalesforcePriceRiseCreationHandler extends CohortHandler {
CohortTableLive.impl(input),
SalesforceClientLive.impl
)
}
}
Original file line number Diff line number Diff line change
@@ -1,24 +1,5 @@
package pricemigrationengine.migrations
import pricemigrationengine.model.{
AddZuoraRatePlan,
AmendmentDataFailure,
Monthly,
ChargeOverride,
BillingPeriod,
CohortSpec,
Currency,
DigiSubs2023,
Failure,
MigrationType,
Annual,
Quarterly,
PriceData,
RemoveZuoraRatePlan,
ZuoraRatePlan,
ZuoraRatePlanCharge,
ZuoraSubscription,
ZuoraSubscriptionUpdate
}
import pricemigrationengine.model._

import java.time.LocalDate

Expand Down Expand Up @@ -97,6 +78,7 @@ object DigiSubs2023Migration {
case None => Left(AmendmentDataFailure(s"Could not determine a new annual price for currency: ${currency}"))
case Some(price) => Right(price)
}
case SemiAnnual => Left(AmendmentDataFailure(s"There are no defined semi-annual prices for this migration"))
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package pricemigrationengine.migrations.newspaper2024Migration
import pricemigrationengine.model._
import pricemigrationengine.migrations.newspaper2024Migration.StaticData._
import pricemigrationengine.migrations.newspaper2024Migration.Estimation._

import java.time.LocalDate

object Amendment {

def subscriptionToNewChargeDistribution2024(subscription: ZuoraSubscription): Option[ChargeDistribution2024] = {
val priceCorrectionFactor = PriceCapping.priceCorrectionFactor(subscription)
for {
data2024 <- Estimation
.subscriptionToSubscriptionData2024(subscription)
.toOption
priceDistribution <- StaticData.priceDistributionLookup(
data2024.productName,
data2024.billingPeriod,
data2024.ratePlanName
)
} yield chargeDistributionMultiplier(priceDistribution, priceCorrectionFactor)
}

def chargeDistributionToChargeOverrides(
distribution: ChargeDistribution2024,
billingPeriod: String
): List[ChargeOverride] = {
List(
distribution.monday,
distribution.tuesday,
distribution.wednesday,
distribution.thursday,
distribution.friday,
distribution.saturday,
distribution.sunday,
distribution.digitalPack,
).flatten.map { individualCharge =>
ChargeOverride(
productRatePlanChargeId = individualCharge.chargeId,
billingPeriod = billingPeriod,
price = individualCharge.Price
)
}
}

def subscriptionToZuoraSubscriptionUpdate(
subscription: ZuoraSubscription,
effectiveDate: LocalDate,
): Either[AmendmentDataFailure, ZuoraSubscriptionUpdate] = {
for {
data2024 <- Estimation.subscriptionToSubscriptionData2024(subscription).left.map(AmendmentDataFailure)
chargeDistribution <- subscriptionToNewChargeDistribution2024(subscription).toRight(AmendmentDataFailure("error"))
} yield ZuoraSubscriptionUpdate(
add = List(
AddZuoraRatePlan(
productRatePlanId = data2024.targetRatePlanId,
contractEffectiveDate = effectiveDate,
chargeOverrides =
chargeDistributionToChargeOverrides(chargeDistribution, BillingPeriod.toString(data2024.billingPeriod))
)
),
remove = List(
RemoveZuoraRatePlan(
ratePlanId = data2024.ratePlan.id,
effectiveDate
)
),
currentTerm = None,
currentTermPeriodType = None
)
}
}
Loading

0 comments on commit 555cc7e

Please sign in to comment.