diff --git a/lambda/src/main/scala/pricemigrationengine/handlers/EstimationHandler.scala b/lambda/src/main/scala/pricemigrationengine/handlers/EstimationHandler.scala index 85e1c1f9..27dc8403 100644 --- a/lambda/src/main/scala/pricemigrationengine/handlers/EstimationHandler.scala +++ b/lambda/src/main/scala/pricemigrationengine/handlers/EstimationHandler.scala @@ -103,18 +103,14 @@ object EstimationHandler extends CohortHandler { } def handle(input: CohortSpec): ZIO[Logging, Failure, HandlerOutput] = { - MigrationType(input) match { - case GW2024 => ZIO.succeed(HandlerOutput(isComplete = true)) - case _ => - main(input).provideSome[Logging]( - EnvConfig.cohortTable.layer, - EnvConfig.zuora.layer, - EnvConfig.stage.layer, - DynamoDBZIOLive.impl, - DynamoDBClientLive.impl, - CohortTableLive.impl(input), - ZuoraLive.impl - ) - } + main(input).provideSome[Logging]( + EnvConfig.cohortTable.layer, + EnvConfig.zuora.layer, + EnvConfig.stage.layer, + DynamoDBZIOLive.impl, + DynamoDBClientLive.impl, + CohortTableLive.impl(input), + ZuoraLive.impl + ) } } diff --git a/lambda/src/main/scala/pricemigrationengine/migrations/GW2024Migration.scala b/lambda/src/main/scala/pricemigrationengine/migrations/GW2024Migration.scala index 1987e87c..e52acd0d 100644 --- a/lambda/src/main/scala/pricemigrationengine/migrations/GW2024Migration.scala +++ b/lambda/src/main/scala/pricemigrationengine/migrations/GW2024Migration.scala @@ -1,6 +1,7 @@ package pricemigrationengine.migrations import pricemigrationengine.model._ +import pricemigrationengine.util._ import java.time.LocalDate object GW2024Migration { @@ -38,11 +39,109 @@ object GW2024Migration { "ROW (USD)" -> BigDecimal(396) ) + def getNewPrice(billingPeriod: BillingPeriod, extendedCurrency: Currency): Option[BigDecimal] = { + billingPeriod match { + case Monthly => priceMapMonthlies.get(extendedCurrency) + case Quarterly => priceMapQuarterlies.get(extendedCurrency) + case Annual => priceMapAnnuals.get(extendedCurrency) + case _ => None + } + } + + // ------------------------------------------------ + // Data Extraction and Manipulation + // ------------------------------------------------ + + def subscriptionToMigrationRatePlans(subscription: ZuoraSubscription): List[ZuoraRatePlan] = { + val migrationRatePlanNames = List( + "GW Oct 18 - Monthly - Domestic", + "GW Oct 18 - Quarterly - Domestic", + "GW Oct 18 - Annual - Domestic", + "GW Oct 18 - Monthly - ROW", + "GW Oct 18 - Quarterly - ROW", + "GW Oct 18 - Annual - ROW", + "Guardian Weekly Quarterly", + "Guardian Weekly Annual", + "GW Oct 18 - 1 Year - Domestic", + "Guardian Weekly 1 Year", + ) + subscription.ratePlans.filter(rp => migrationRatePlanNames.contains(rp.ratePlanName)) + } + + def subscriptionToMigrationRatePlan(subscription: ZuoraSubscription): Option[ZuoraRatePlan] = { + subscriptionToMigrationRatePlans(subscription: ZuoraSubscription).headOption + } + + def subscriptionToCurrency( + subscription: ZuoraSubscription, + account: ZuoraAccount + ): Option[Currency] = { + for { + ratePlan <- subscriptionToMigrationRatePlan(subscription) + currency <- ZuoraRatePlan.ratePlanToCurrency(ratePlan: ZuoraRatePlan) + } yield currency + } + + def isROW(subscription: ZuoraSubscription, account: ZuoraAccount): Option[Boolean] = { + for { + ratePlan <- subscriptionToMigrationRatePlan(subscription: ZuoraSubscription) + currency <- ZuoraRatePlan.ratePlanToCurrency(ratePlan: ZuoraRatePlan) + } yield { + val country = account.soldToContact.country + currency == "USD" && country != "United States" + } + } + + def subscriptionToExtendedCurrency( + subscription: ZuoraSubscription, + account: ZuoraAccount + ): Option[Currency] = { + for { + currency <- subscriptionToCurrency(subscription, account) + isROW <- isROW(subscription: ZuoraSubscription, account: ZuoraAccount) + } yield if (isROW) "ROW (USD)" else currency + } + + def subscriptionToBillingPeriod(subscription: ZuoraSubscription): Option[BillingPeriod] = { + for { + ratePlan <- subscriptionToMigrationRatePlan(subscription) + billingPeriod <- ZuoraRatePlan.ratePlanToBillingPeriod(ratePlan) + } yield billingPeriod + } + + def getNewPrice(subscription: ZuoraSubscription, account: ZuoraAccount): Option[BigDecimal] = { + for { + billingPeriod <- subscriptionToBillingPeriod(subscription) + extendedCurrency <- subscriptionToExtendedCurrency(subscription, account) + price <- getNewPrice(billingPeriod, extendedCurrency) + } yield price + } + + def subscriptionToLastPriceMigrationDate(subscription: ZuoraSubscription): Option[LocalDate] = { + Some( + subscriptionToMigrationRatePlans(subscription) + .flatMap(ratePlan => ratePlan.ratePlanCharges) + .flatMap(rpc => rpc.originalOrderDate) + .foldLeft(LocalDate.of(2000, 1, 1))((acc, date) => Date.datesMax(acc, date)) + ) + } + def priceData( subscription: ZuoraSubscription, account: ZuoraAccount ): Either[AmendmentDataFailure, PriceData] = { - ??? + val priceDataOpt = for { + currency <- subscriptionToCurrency(subscription, account) + ratePlan <- subscriptionToMigrationRatePlan(subscription) + oldPrice = ZuoraRatePlan.ratePlanToRatePlanPrice(ratePlan) + newPrice <- getNewPrice(subscription, account) + billingPeriod <- subscriptionToBillingPeriod(subscription) + } yield PriceData(currency, oldPrice, newPrice, BillingPeriod.toString(billingPeriod)) + priceDataOpt match { + case Some(pricedata) => Right(pricedata) + case None => + Left(AmendmentDataFailure(s"Could not determine PriceData for subscription ${subscription.subscriptionNumber}")) + } } def zuoraUpdate( diff --git a/lambda/src/main/scala/pricemigrationengine/model/ZuoraRatePlan.scala b/lambda/src/main/scala/pricemigrationengine/model/ZuoraRatePlan.scala index 6c985782..7c8f71b3 100644 --- a/lambda/src/main/scala/pricemigrationengine/model/ZuoraRatePlan.scala +++ b/lambda/src/main/scala/pricemigrationengine/model/ZuoraRatePlan.scala @@ -31,4 +31,10 @@ object ZuoraRatePlan { billingPeriod <- ratePlanCharge.billingPeriod } yield BillingPeriod.fromString(billingPeriod) } + + def ratePlanToRatePlanPrice(ratePlan: ZuoraRatePlan): BigDecimal = { + ratePlan.ratePlanCharges.foldLeft(BigDecimal(0))((price: BigDecimal, ratePlanCharge: ZuoraRatePlanCharge) => + price + ratePlanCharge.price.getOrElse(BigDecimal(0)) + ) + } } diff --git a/lambda/src/main/scala/pricemigrationengine/util/StartDates.scala b/lambda/src/main/scala/pricemigrationengine/util/StartDates.scala index 84ce9fb8..071ac334 100644 --- a/lambda/src/main/scala/pricemigrationengine/util/StartDates.scala +++ b/lambda/src/main/scala/pricemigrationengine/util/StartDates.scala @@ -1,7 +1,7 @@ package pricemigrationengine.util import pricemigrationengine.handlers.NotificationHandler -import pricemigrationengine.migrations.newspaper2024Migration +import pricemigrationengine.migrations.{GW2024Migration, newspaper2024Migration} import pricemigrationengine.model._ import zio.{IO, Random} @@ -25,9 +25,12 @@ object StartDates { } // This function returns the optional date of the last price rise. - // Will get a non trivial implementation in the GW2024 migration code - def lastPriceRiseDate(cohortSpec: CohortSpec, subscription: ZuoraSubscription): Option[LocalDate] = None - + def lastPriceRiseDate(cohortSpec: CohortSpec, subscription: ZuoraSubscription): Option[LocalDate] = { + MigrationType(cohortSpec) match { + case GW2024 => GW2024Migration.subscriptionToLastPriceMigrationDate(subscription) + case _ => None + } + } def cohortSpecLowerBound( cohortSpec: CohortSpec, today: LocalDate diff --git a/lambda/src/test/scala/pricemigrationengine/migrations/GW2024MigrationTest.scala b/lambda/src/test/scala/pricemigrationengine/migrations/GW2024MigrationTest.scala new file mode 100644 index 00000000..b14e7dcf --- /dev/null +++ b/lambda/src/test/scala/pricemigrationengine/migrations/GW2024MigrationTest.scala @@ -0,0 +1,261 @@ +package pricemigrationengine.migrations + +import pricemigrationengine.model._ + +import java.time.LocalDate +import pricemigrationengine.Fixtures +import pricemigrationengine.migrations.GW2024Migration +import pricemigrationengine.migrations.GW2024Migration +import pricemigrationengine.util.StartDates + +class GW2024MigrationTest extends munit.FunSuite { + + test("Price lookup (version 1) is correct") { + assertEquals( + GW2024Migration.getNewPrice(Monthly, "GBP"), + Some(BigDecimal(15)) + ) + assertEquals( + GW2024Migration.getNewPrice(Quarterly, "ROW (USD)"), + Some(BigDecimal(99)) + ) + } + + // ------------------------------------- + + test("Rate plan determination is correct (standard)") { + val subscription = Fixtures.subscriptionFromJson("GW2024/standard/subscription.json") + assertEquals( + GW2024Migration.subscriptionToMigrationRatePlan(subscription), + Some( + ZuoraRatePlan( + id = "8a128d6988b434050188d210ef897dc2", + productName = "Guardian Weekly - Domestic", + productRatePlanId = "2c92a0fe6619b4b901661aa8e66c1692", + ratePlanName = "GW Oct 18 - Annual - Domestic", + ratePlanCharges = List( + ZuoraRatePlanCharge( + productRatePlanChargeId = "2c92a0fe6619b4b901661aa8e6811695", + name = "GW Oct 18 - Annual - Domestic", + number = "C-02169680", + currency = "USD", + price = Some(BigDecimal(300.0)), + billingPeriod = Some("Annual"), + chargedThroughDate = Some(LocalDate.of(2024, 6, 19)), + processedThroughDate = Some(LocalDate.of(2023, 6, 19)), + specificBillingPeriod = None, + endDateCondition = Some("Subscription_End"), + upToPeriodsType = None, + upToPeriods = None, + billingDay = Some("ChargeTriggerDay"), + triggerEvent = Some("CustomerAcceptance"), + triggerDate = None, + discountPercentage = None, + originalOrderDate = Some(LocalDate.of(2020, 6, 8)) + ) + ), + lastChangeType = Some("Add") + ) + ) + ) + } + + // ------------------------------------- + + test("subscriptionToMigrationCurrency is correct (standard)") { + val subscription = Fixtures.subscriptionFromJson("GW2024/standard/subscription.json") + val account = Fixtures.accountFromJson("GW2024/standard/account.json") + assertEquals( + GW2024Migration.subscriptionToCurrency(subscription, account), + Some("USD") + ) + } + + test("subscriptionToMigrationCurrency is correct (ROW-DomesticRatePlan)") { + val subscription = Fixtures.subscriptionFromJson("GW2024/ROW-DomesticRatePlan/subscription.json") + val account = Fixtures.accountFromJson("GW2024/ROW-DomesticRatePlan/account.json") + assertEquals( + GW2024Migration.subscriptionToCurrency(subscription, account), + Some("USD") + ) + } + + // ------------------------------------- + + test("isROW is correct (standard: USD paying from the US)") { + val subscription = Fixtures.subscriptionFromJson("GW2024/standard/subscription.json") + val account = Fixtures.accountFromJson("GW2024/standard/account.json") + assertEquals( + GW2024Migration.isROW(subscription, account), + Some(false) + ) + } + + test("isROW is correct (row: USD paying from Hong Kong)") { + val subscription = Fixtures.subscriptionFromJson("GW2024/ROW-DomesticRatePlan/subscription.json") + val account = Fixtures.accountFromJson("GW2024/ROW-DomesticRatePlan/account.json") + assertEquals( + GW2024Migration.isROW(subscription, account), + Some(true) + ) + } + + test("isROW is correct (row: USD paying from United Arab Emirates)") { + val subscription = Fixtures.subscriptionFromJson("GW2024/ROW-DomesticRatePlan/subscription.json") + val account = Fixtures.accountFromJson("GW2024/ROW-DomesticRatePlan/account.json") + assertEquals( + GW2024Migration.isROW(subscription, account), + Some(true) + ) + } + + // ------------------------------------- + + test("subscriptionToExtendedCurrency (standard)") { + val subscription = Fixtures.subscriptionFromJson("GW2024/standard/subscription.json") + val account = Fixtures.accountFromJson("GW2024/standard/account.json") + assertEquals( + GW2024Migration.subscriptionToExtendedCurrency(subscription, account), + Some("USD") + ) + } + + test("subscriptionToExtendedCurrency (ROW-DomesticRatePlan)") { + val subscription = Fixtures.subscriptionFromJson("GW2024/ROW-DomesticRatePlan/subscription.json") + val account = Fixtures.accountFromJson("GW2024/ROW-DomesticRatePlan/account.json") + assertEquals( + GW2024Migration.subscriptionToExtendedCurrency(subscription, account), + Some("ROW (USD)") + ) + } + + // ------------------------------------- + + test("subscriptionToBillingPeriod") { + val subscription = Fixtures.subscriptionFromJson("GW2024/standard/subscription.json") + assertEquals( + GW2024Migration.subscriptionToBillingPeriod(subscription), + Some(Annual) + ) + } + + // ------------------------------------- + + test("getNewPrice (version 2) (standard)") { + val subscription = Fixtures.subscriptionFromJson("GW2024/standard/subscription.json") + val account = Fixtures.accountFromJson("GW2024/standard/account.json") + assertEquals( + GW2024Migration.getNewPrice(subscription, account), + Some(BigDecimal(360)) + ) + } + + test("getNewPrice (version 2) (ROW-DomesticRatePlan)") { + val subscription = Fixtures.subscriptionFromJson("GW2024/ROW-DomesticRatePlan/subscription.json") + val account = Fixtures.accountFromJson("GW2024/ROW-DomesticRatePlan/account.json") + assertEquals( + GW2024Migration.getNewPrice(subscription, account), + Some(BigDecimal(396)) + ) + } + + // ------------------------------------- + + test("priceData (standard)") { + val subscription = Fixtures.subscriptionFromJson("GW2024/standard/subscription.json") + val account = Fixtures.accountFromJson("GW2024/standard/account.json") + assertEquals( + GW2024Migration.priceData(subscription, account), + Right(PriceData("USD", BigDecimal(300), BigDecimal(360), "Annual")) + ) + } + + test("priceData (ROW-DomesticRatePlan)") { + val subscription = Fixtures.subscriptionFromJson("GW2024/ROW-DomesticRatePlan/subscription.json") + val account = Fixtures.accountFromJson("GW2024/ROW-DomesticRatePlan/account.json") + assertEquals( + GW2024Migration.priceData(subscription, account), + Right(PriceData("USD", BigDecimal(300), BigDecimal(396), "Annual")) + ) + } + + // ------------------------------------ + + test("last price rise date") { + val subscription = Fixtures.subscriptionFromJson("GW2024/standard/subscription.json") + assertEquals( + GW2024Migration.subscriptionToLastPriceMigrationDate(subscription), + Some(LocalDate.of(2020, 6, 8)) + ) + } + + test("last price rise date") { + val subscription = Fixtures.subscriptionFromJson("GW2024/NotTwoPriceRisesWithinAYear/subscription.json") + assertEquals( + GW2024Migration.subscriptionToLastPriceMigrationDate(subscription), + Some(LocalDate.of(2023, 7, 5)) + ) + } + + // ------------------------------------ + + test("StartDate [no price rise within a year of the last price rise] policy (trivial case)") { + val subscription = Fixtures.subscriptionFromJson("GW2024/standard/subscription.json") + // The last price rise date for this subscription is LocalDate.of(2020, 6, 8) + // That's more that a year ago at the time these lines are written (2024-03-13) + // Therefore the StartDates policy function is not going to increase the lower bound if + // we set the lower bound to today: 2024-03-13. + + val cohortSpec = CohortSpec("GW2024", "", LocalDate.of(2024, 1, 1), LocalDate.of(2024, 1, 1)) + assertEquals( + StartDates.noPriceRiseWithinAYearOfLastPriceRisePolicyUpdate(cohortSpec, subscription, LocalDate.of(2024, 3, 13)), + LocalDate.of(2024, 3, 13) + ) + } + + test("StartDate [no price rise within a year of the last price rise] policy (non trivial case)") { + val subscription = Fixtures.subscriptionFromJson("GW2024/NotTwoPriceRisesWithinAYear/subscription.json") + // The last price rise date for this subscription is LocalDate.of(2023, 7, 5) + // That's less than a year ago at the time these lines are written (2024-03-13) + // Therefore the StartDates policy function is going to increase the lower bound to 2024-07-05 (one year after + // last price rise) if we present it with a previous lower bound of 2024-03-13. + + val cohortSpec = CohortSpec("GW2024", "", LocalDate.of(2024, 1, 1), LocalDate.of(2024, 1, 1)) + assertEquals( + StartDates.noPriceRiseWithinAYearOfLastPriceRisePolicyUpdate(cohortSpec, subscription, LocalDate.of(2024, 3, 13)), + LocalDate.of(2024, 7, 5) + ) + } + + // ------------------------------------ + + test("EstimationResult") { + val subscription = Fixtures.subscriptionFromJson("GW2024/standard/subscription.json") + val invoicePreview = + Fixtures.invoiceListFromJson("GW2024/standard/invoice-preview.json") + val account = Fixtures.accountFromJson("GW2024/standard/account.json") + val catalogue = Fixtures.productCatalogueFromJson("GW2024/standard/catalogue.json") + + val cohortSpec = CohortSpec("GW2024", "", LocalDate.of(2024, 1, 1), LocalDate.of(2024, 5, 20)) + + val startDateLowerBound = LocalDate.of(2025, 1, 1) + + val estimationResult = + EstimationResult(account, catalogue, subscription, invoicePreview, startDateLowerBound, cohortSpec) + + assertEquals( + estimationResult, + Right( + EstimationData( + subscriptionName = "SUBSCRIPTION-NUMBER", + startDate = LocalDate.of(2025, 6, 19), + currency = "USD", + oldPrice = BigDecimal(300.0), + estimatedNewPrice = BigDecimal(360), + billingPeriod = "Annual" + ) + ) + ) + } + +}