From aaf823e958dabdeb600e45a4b85265e9680f23bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Honor=C3=A9?= Date: Tue, 3 Sep 2024 17:00:38 +0100 Subject: [PATCH] update EmailPayloadSubscriberAttributes with attributes for SupporterPlus2024 --- README.md | 2 +- docs/coding-conventions.md | 8 - docs/coding-directives.md | 8 + .../handlers/NotificationHandler.scala | 70 +++- .../SupporterPlus2024Migration.scala | 95 ++++- .../pricemigrationengine/model/PriceCap.scala | 13 +- .../membershipworkflow/EmailMessage.scala | 53 ++- .../annual/subscription.json | 8 +- .../subscription.json | 10 +- .../SupporterPlus2024MigrationTest.scala | 375 +++++++++++++++++- 10 files changed, 610 insertions(+), 32 deletions(-) delete mode 100644 docs/coding-conventions.md create mode 100644 docs/coding-directives.md diff --git a/README.md b/README.md index 430b182f..65c3f9e7 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ The price migration engine is an orchestration engine used to perform controlled - [An introduction to the general principles of price migrations](docs/price-migrations-from-first-principles.md) - [The journey of a cohort item](docs/the-journey-of-a-cohort-item.md) -- [Coding conventions](docs/coding-conventions.md) +- [Coding directives](docs/coding-directives.md) - [Notification windows](docs/notification-windows.md) - [The art of computing start dates](docs/start-date-computation.md) - [The art of the cap; or how to gracefully cap prices in the engine](docs/the-art-of-the-cap.md) diff --git a/docs/coding-conventions.md b/docs/coding-conventions.md deleted file mode 100644 index 2c965542..00000000 --- a/docs/coding-conventions.md +++ /dev/null @@ -1,8 +0,0 @@ -# Coding Conventions - -The price migration engine doesn't have coding conventions per se. ZIO does a very good job at keeping sanity between pure and impure code, and putting adhoc code into migration specific objects (the so called "modern" migrations) helps separate the general engine logic from specific requests. We also rely on the coding expertise of contributors to do the right thing (including breaking rules when needed). - -With that said, we have the following conventions - -- When using `MigrationType(cohortSpec)` to dispatch values or behaviour per migration, and unless exceptions (there are a couple in the code for when we handle exceptions or for exceptional circumstances), we will be explicit on what we want and declaring all the cases. If somebody is implementing a new migration and follows the steps Pascal presented during GW2024, then declaring a new case will happen during the [first step](https://github.com/guardian/price-migration-engine/pull/1012). The reason for this rule is that an inexperienced contributor could easily miss a place in the code where some thought should have been given on what a new migration should specify. If the code compiles without prompting that decision the contributor might miss it. And even if the decision is to go with the "default", this needs to be explicitly specified. This convention was introduced in this pull request [pull:1022](https://github.com/guardian/price-migration-engine/pull/1022). - diff --git a/docs/coding-directives.md b/docs/coding-directives.md new file mode 100644 index 00000000..647750da --- /dev/null +++ b/docs/coding-directives.md @@ -0,0 +1,8 @@ +# Coding Conventions + +The price migration engine doesn't have coding conventions per se. ZIO does a very good job at keeping sanity between pure and impure code, and putting adhoc code into migration specific objects (the so called "modern" migrations) helps separate the general engine logic from specific requests. We also rely on the coding expertise of contributors to do the right thing (including breaking rules when needed). + +With that said, we have the following conventions + +- Coding Directive #1: When using `MigrationType(cohortSpec)` to dispatch values or behaviour per migration, and unless exceptions (there are a couple in the code for when we handle exceptions or for exceptional circumstances), we will be explicit on what we want and declaring all the cases. If somebody is implementing a new migration and follows the steps Pascal presented during GW2024, then declaring a new case will happen during the [first step](https://github.com/guardian/price-migration-engine/pull/1012). The reason for this rule is that an inexperienced contributor could easily miss a place in the code where some thought should have been given on what a new migration should specify. If the code compiles without prompting that decision the contributor might miss it. And even if the decision is to go with the "default", this needs to be explicitly specified. This convention was introduced in this pull request [pull:1022](https://github.com/guardian/price-migration-engine/pull/1022). + diff --git a/lambda/src/main/scala/pricemigrationengine/handlers/NotificationHandler.scala b/lambda/src/main/scala/pricemigrationengine/handlers/NotificationHandler.scala index dc464da1..cd670bd1 100644 --- a/lambda/src/main/scala/pricemigrationengine/handlers/NotificationHandler.scala +++ b/lambda/src/main/scala/pricemigrationengine/handlers/NotificationHandler.scala @@ -11,7 +11,7 @@ import pricemigrationengine.migrations.{ GW2024Migration, Membership2023Migration, newspaper2024Migration, - SupporterPlus2024Migration + SupporterPlus2024Migration, } import pricemigrationengine.model.RateplansProbe @@ -91,7 +91,7 @@ object NotificationHandler extends CohortHandler { cohortSpec: CohortSpec, cohortItem: CohortItem, sfSubscription: SalesforceSubscription - ): ZIO[EmailSender with SalesforceClient with CohortTable with Logging, Failure, Unit] = + ): ZIO[Zuora with EmailSender with SalesforceClient with CohortTable with Logging, Failure, Unit] = for { _ <- Logging.info(s"Processing subscription: ${cohortItem.subscriptionName}") contact <- SalesforceClient.getContact(sfSubscription.Buyer__c) @@ -123,6 +123,12 @@ object NotificationHandler extends CohortHandler { _ <- logMissingEmailAddress(cohortItem, contact) + // Data for SupporterPlus2024 + subscription <- Zuora.fetchSubscription(cohortItem.subscriptionName) + sp2024_contribution_amount <- ZIO.fromEither(sp2024_contribution_amount(cohortSpec, subscription)) + sp2024_previous_combined_amount <- ZIO.fromEither(sp2024_previous_combined_amount(cohortSpec, subscription)) + sp2024_new_combined_amount <- ZIO.fromEither(sp2024_new_combined_amount(cohortSpec, subscription)) + _ <- EmailSender.sendEmail( message = EmailMessage( EmailPayload( @@ -144,7 +150,12 @@ object NotificationHandler extends CohortHandler { next_payment_date = startDateConversion(startDate), payment_frequency = paymentFrequency, subscription_id = cohortItem.subscriptionName, - product_type = sfSubscription.Product_Type__c.getOrElse("") + product_type = sfSubscription.Product_Type__c.getOrElse(""), + + // SupporterPlus 2024 extension + sp2024_contribution_amount = sp2024_contribution_amount, + sp2024_previous_combined_amount = sp2024_previous_combined_amount, + sp2024_new_combined_amount = sp2024_new_combined_amount, ) ) ), @@ -434,4 +445,57 @@ object NotificationHandler extends CohortHandler { ) } yield () } + + // ------------------------------------------------------------------- + // Supporter Plus 2024 extra functions + + /* + Date: September 2024 + Author: Pascal + Comment Group: 602514a6-5e53 + + These functions have been added to implement the extra fields added to EmailPayloadSubscriberAttributes + as part of the set up of the SupporterPlus 2024 migration (see Comment Group: 602514a6-5e53) + */ + + def sp2024_contribution_amount( + cohortSpec: CohortSpec, + subscription: ZuoraSubscription + ): Either[Failure, Option[String]] = { + // nb: Going against Coding Directive #1, we do not need to over specify the match, because we are expecting + // a non trivial behavior only for SupporterPlus2024 + MigrationType(cohortSpec) match { + case SupporterPlus2024 => + SupporterPlus2024Migration.sp2024_contribution_amount(subscription).map(o => o.map(b => b.toString)) + case _ => Right(None) + } + } + + def sp2024_previous_combined_amount( + cohortSpec: CohortSpec, + subscription: ZuoraSubscription + ): Either[Failure, Option[String]] = { + // nb: Going against Coding Directive #1, we do not need to over specify the match, because we are expecting + // a non trivial behavior only for SupporterPlus2024 + MigrationType(cohortSpec) match { + case SupporterPlus2024 => + SupporterPlus2024Migration + .sp2024_previous_combined_amount(subscription) + .map(o => o.map(b => b.toString)) + case _ => Right(None) + } + } + + def sp2024_new_combined_amount( + cohortSpec: CohortSpec, + subscription: ZuoraSubscription + ): Either[Failure, Option[String]] = { + // nb: Going against Coding Directive #1, we do not need to over specify the match, because we are expecting + // a non trivial behavior only for SupporterPlus2024 + MigrationType(cohortSpec) match { + case SupporterPlus2024 => + SupporterPlus2024Migration.sp2024_new_combined_amount(subscription).map(o => o.map(b => b.toString)) + case _ => Right(None) + } + } } diff --git a/lambda/src/main/scala/pricemigrationengine/migrations/SupporterPlus2024Migration.scala b/lambda/src/main/scala/pricemigrationengine/migrations/SupporterPlus2024Migration.scala index df6691aa..72e263ff 100644 --- a/lambda/src/main/scala/pricemigrationengine/migrations/SupporterPlus2024Migration.scala +++ b/lambda/src/main/scala/pricemigrationengine/migrations/SupporterPlus2024Migration.scala @@ -1,8 +1,6 @@ package pricemigrationengine.migrations -import pricemigrationengine.model.PriceCap import pricemigrationengine.model.ZuoraRatePlan import pricemigrationengine.model._ -import pricemigrationengine.util._ import java.time.LocalDate @@ -55,6 +53,7 @@ object SupporterPlus2024Migration { // Data Functions // ------------------------------------------------ + // ------------------------------------------------ // Prices def getOldPrice(billingPeriod: BillingPeriod, currency: String): Option[Double] = { @@ -73,6 +72,7 @@ object SupporterPlus2024Migration { } } + // ------------------------------------------------ // Cancellation Saves def cancellationSaveRatePlan(subscription: ZuoraSubscription): Option[ZuoraRatePlan] = { @@ -91,6 +91,7 @@ object SupporterPlus2024Migration { } yield date } + // ------------------------------------------------ // Subscription Data def supporterPlusV2RatePlan(subscription: ZuoraSubscription): Either[AmendmentDataFailure, ZuoraRatePlan] = { @@ -114,13 +115,101 @@ object SupporterPlus2024Migration { ratePlan.ratePlanCharges.find(rpc => rpc.name.contains("Supporter Plus")) match { case None => { Left( - AmendmentDataFailure(s"Subscription ${subscriptionNumber} has a rate plan, but with no charge") + AmendmentDataFailure(s"Subscription ${subscriptionNumber} has a rate plan (${ratePlan}), but with no charge") + ) + } + case Some(ratePlanCharge) => Right(ratePlanCharge) + } + } + + def supporterPlusContributionRatePlanCharge( + subscriptionNumber: String, + ratePlan: ZuoraRatePlan + ): Either[AmendmentDataFailure, ZuoraRatePlanCharge] = { + ratePlan.ratePlanCharges.find(rpc => rpc.name.contains("Contribution")) match { + case None => { + Left( + AmendmentDataFailure(s"Subscription ${subscriptionNumber} has a rate plan (${ratePlan}), but with no charge") ) } case Some(ratePlanCharge) => Right(ratePlanCharge) } } + // ------------------------------------------------ + // Notification helpers + + /* + Date: September 2024 + Author: Pascal + Comment Group: 602514a6-5e53 + + These functions have been added to implement the extra fields added to EmailPayloadSubscriberAttributes + as part of the set up of the SupporterPlus 2024 migration (see Comment Group: 602514a6-5e53) + */ + + def sp2024_previous_base_amount(subscription: ZuoraSubscription): Either[Failure, Option[BigDecimal]] = { + for { + ratePlan <- supporterPlusV2RatePlan(subscription) + ratePlanCharge <- supporterPlusBaseRatePlanCharge(subscription.subscriptionNumber, ratePlan) + } yield ratePlanCharge.price + } + + def sp2024_new_base_amount(subscription: ZuoraSubscription): Either[Failure, Option[BigDecimal]] = { + for { + ratePlan <- supporterPlusV2RatePlan(subscription) + billingPeriod <- ZuoraRatePlan.ratePlanToBillingPeriod(ratePlan).toRight(AmendmentDataFailure("")) + ratePlanCharge <- supporterPlusBaseRatePlanCharge(subscription.subscriptionNumber, ratePlan) + currency = ratePlanCharge.currency + oldBaseAmountOpt <- sp2024_previous_base_amount(subscription) + oldBaseAmount <- oldBaseAmountOpt.toRight( + AmendmentDataFailure( + s"(error: 164d8f1c-6dc6) could not extract base amount for subscription ${subscription.subscriptionNumber}" + ) + ) + newPriceFromPriceGrid <- getNewPrice(billingPeriod, currency) + .map(BigDecimal(_)) + .toRight( + AmendmentDataFailure( + s"(error: 611aedea-0478) could not getNewPrice for (billingPeriod, currency) and (${billingPeriod}, ${currency})" + ) + ) + } yield { + Some((oldBaseAmount * BigDecimal(1.27)).min(newPriceFromPriceGrid)) + } + } + + def sp2024_contribution_amount(subscription: ZuoraSubscription): Either[Failure, Option[BigDecimal]] = { + for { + ratePlan <- supporterPlusV2RatePlan(subscription) + ratePlanCharge <- supporterPlusContributionRatePlanCharge(subscription.subscriptionNumber, ratePlan) + } yield ratePlanCharge.price + } + + def sp2024_previous_combined_amount(subscription: ZuoraSubscription): Either[Failure, Option[BigDecimal]] = { + for { + contributionAmountOpt <- sp2024_contribution_amount(subscription) + previousBaseAmountOpt <- sp2024_previous_base_amount(subscription) + } yield ( + for { + contributionAmount <- contributionAmountOpt + previousBaseAmount <- previousBaseAmountOpt + } yield contributionAmount + previousBaseAmount + ) + } + + def sp2024_new_combined_amount(subscription: ZuoraSubscription): Either[Failure, Option[BigDecimal]] = { + for { + contributionAmountOpt <- sp2024_contribution_amount(subscription) + previousBaseAmountOpt <- sp2024_new_base_amount(subscription) + } yield ( + for { + contributionAmount <- contributionAmountOpt + previousBaseAmount <- previousBaseAmountOpt + } yield contributionAmount + previousBaseAmount + ) + } + // ------------------------------------------------ // Primary Interface // ------------------------------------------------ diff --git a/lambda/src/main/scala/pricemigrationengine/model/PriceCap.scala b/lambda/src/main/scala/pricemigrationengine/model/PriceCap.scala index d317e621..76ee9023 100644 --- a/lambda/src/main/scala/pricemigrationengine/model/PriceCap.scala +++ b/lambda/src/main/scala/pricemigrationengine/model/PriceCap.scala @@ -22,13 +22,20 @@ object PriceCap { // We have separate functions for determining the // estimated new price and for computing the adjusted ZuoraSubscriptionUpdate. Unlike the now obsolete part 1 // We design those functions with the signature that is actually useful for price cap, where we will need to apply it - // 1. The notification handler, and - // 2. The SalesforcePriceRiseCreationHandler, and - // 3. The amendment handler + // 1. The SalesforcePriceRiseCreationHandler (priceCapForNotification), and + // 2. The notification handler (priceCapForNotification), and + // 3. The amendment handler (priceCapForAmendment) // Note: We should not apply capping during the estimation step and capped prices should not be written in the // migration dynamo tables ! + // Note: These functions work better for simple price migrations where the subscription has one price + // that we update. In the case of SupporterPlus subscriptions, with their base price and extra optional + // contribution (see example of the SupporterPlus2024 price migration), the base price is price risen and + // capped, but the subscription price is the sum of that new price together with the contribution. In a + // case like that we just implemented the price capping in the migration code itself, without messing around + // with this code. + def priceCapForNotification(oldPrice: BigDecimal, newPrice: BigDecimal, cap: BigDecimal): BigDecimal = { // The cap is the price cap expressed as a multiple of the old price. For instance for a price cap // of 20%, we will use a cap equal to 1.2 diff --git a/lambda/src/main/scala/pricemigrationengine/model/membershipworkflow/EmailMessage.scala b/lambda/src/main/scala/pricemigrationengine/model/membershipworkflow/EmailMessage.scala index 38fc4700..22037f5b 100644 --- a/lambda/src/main/scala/pricemigrationengine/model/membershipworkflow/EmailMessage.scala +++ b/lambda/src/main/scala/pricemigrationengine/model/membershipworkflow/EmailMessage.scala @@ -18,9 +18,60 @@ case class EmailPayloadSubscriberAttributes( next_payment_date: String, payment_frequency: String, subscription_id: String, - product_type: String + product_type: String, + + // SupporterPlus 2024 extension (see comment below) + sp2024_contribution_amount: Option[String] = None, + sp2024_previous_combined_amount: Option[String] = None, + sp2024_new_combined_amount: Option[String] = None, ) +/* + Date: September 2024 + Author: Pascal + Comment Group: 602514a6-5e53 + + This note describes an extension of the EmailPayloadSubscriberAttributes that we are introducing specifically + for the SupporterPlus 2024 migration. + + So far in the history of the engine, we have communicated to the customers the new (post increase) price of their + subscription. This information is carried by payment_amount. + + The SupporterPlus migration is making a price increase on subscriptions which have a base price and an optional + contribution that is at the discretion of the user. For instance some monthly user pay £[10, 0], which means that + the user pays the base price of £10 and no extra contribution, whereas another user would pay £[10, 15], which means + that the user pays the base price of £10 and an extra contribution of £15, leading to a total price of £25. + + In the SupporterPlus2024 migration we are increasing the base price only, for GBP moving from 10 to 12. + + We want to communicate to the user that only the base price is being price increased and not their extra + contribution. More exactly for customers paying no extra contribution we want to say + + """ + Your new price will take effect at your next payment date, on or around {next_payment_date}, and your new payment amount will be {payment_amount} - {payment_frequency}. + """ + + And for customers with an extra contribution with want to say + + """ + Your new subscription price will take effect at your next payment date, on or around {Next Payment Date}. + Your {payment_frequency} contribution of {contribution_amount} remains unchanged, and so your total billing amount will now be {new_combined_amount} - {payment_frequency}. + """ + + We are introducing three new fields + + sp2024_contribution_amount + sp2024_previous_combined_amount + sp2024_new_combined_amount + + Those fields are going to be used by the emails templates that we are going to use for S+ 2024 and + should not be used for any other migrations. + + I am going to decommission them a bit after October 2025, after the end of the migration. I will + nevertheless permanently document this modification in case it is needed again in some remote future + if/when we decide to make another SupporterPlus price migration. + */ + object EmailPayloadSubscriberAttributes { implicit val rw: ReadWriter[EmailPayloadSubscriberAttributes] = macroRW } diff --git a/lambda/src/test/resources/Migrations/SupporterPlus2024/annual/subscription.json b/lambda/src/test/resources/Migrations/SupporterPlus2024/annual/subscription.json index a62b02ea..484de2e5 100644 --- a/lambda/src/test/resources/Migrations/SupporterPlus2024/annual/subscription.json +++ b/lambda/src/test/resources/Migrations/SupporterPlus2024/annual/subscription.json @@ -200,7 +200,7 @@ "mrr" : 13.333333333, "dmrc" : 0.000000000, "tcv" : 0.000000000, - "dtcv" : -160.000000000, + "dtcv" : -150.000000000, "originalOrderDate" : "2023-10-09", "amendedByOrderOn" : "2023-12-01", "description" : "", @@ -211,7 +211,7 @@ "tiers" : null, "discountApplyDetails" : null, "pricingSummary" : "AUD160", - "price" : 160.000000000, + "price" : 150.000000000, "discountAmount" : null, "discountPercentage" : null }, { @@ -354,8 +354,8 @@ "specificEndDate" : null, "mrr" : 13.333333333, "dmrc" : 13.333333333, - "tcv" : 160.000000000, - "dtcv" : 160.000000000, + "tcv" : 150.000000000, + "dtcv" : 150.000000000, "originalOrderDate" : "2023-11-26", "amendedByOrderOn" : null, "description" : "", diff --git a/lambda/src/test/resources/Migrations/SupporterPlus2024/sub-without-LastChangeType/subscription.json b/lambda/src/test/resources/Migrations/SupporterPlus2024/sub-without-LastChangeType/subscription.json index 7c26b25d..714887d4 100644 --- a/lambda/src/test/resources/Migrations/SupporterPlus2024/sub-without-LastChangeType/subscription.json +++ b/lambda/src/test/resources/Migrations/SupporterPlus2024/sub-without-LastChangeType/subscription.json @@ -29,7 +29,7 @@ "renewalTerm" : 12, "renewalTermPeriodType" : "Month", "currency" : "EUR", - "contractedMrr" : 10.00, + "contractedMrr" : 6.00, "totalContractedValue" : 120.00, "notes" : null, "status" : "Active", @@ -279,8 +279,8 @@ "upToPeriodsType" : null, "upToPeriods" : null, "specificEndDate" : null, - "mrr" : 10.000000000, - "dmrc" : 10.000000000, + "mrr" : 6.000000000, + "dmrc" : 6.000000000, "tcv" : 120.000000000, "dtcv" : 120.000000000, "originalOrderDate" : "2024-04-05", @@ -293,7 +293,7 @@ "tiers" : null, "discountApplyDetails" : null, "pricingSummary" : "EUR10", - "price" : 10.000000000, + "price" : 6.000000000, "discountAmount" : null, "discountPercentage" : null }, { @@ -364,7 +364,7 @@ "tiers" : null, "discountApplyDetails" : null, "pricingSummary" : "EUR0", - "price" : 0.000000000, + "price" : 3.000000000, "discountAmount" : null, "discountPercentage" : null } ] diff --git a/lambda/src/test/scala/pricemigrationengine/migrations/SupporterPlus2024MigrationTest.scala b/lambda/src/test/scala/pricemigrationengine/migrations/SupporterPlus2024MigrationTest.scala index ff703c8e..b90ba08f 100644 --- a/lambda/src/test/scala/pricemigrationengine/migrations/SupporterPlus2024MigrationTest.scala +++ b/lambda/src/test/scala/pricemigrationengine/migrations/SupporterPlus2024MigrationTest.scala @@ -21,6 +21,7 @@ class SupporterPlus2024MigrationTest extends munit.FunSuite { true ) } + test("cancellationSaveEffectiveDate") { val subscriptionNo = Fixtures.subscriptionFromJson("Migrations/SupporterPlus2024/sub-with-cancellation-save/subscription-no.json") @@ -35,6 +36,7 @@ class SupporterPlus2024MigrationTest extends munit.FunSuite { Some(LocalDate.of(2024, 7, 5)) ) } + test("Price Grid (Old)") { assertEquals( SupporterPlus2024Migration.getOldPrice(Monthly, "USD"), @@ -58,12 +60,13 @@ class SupporterPlus2024MigrationTest extends munit.FunSuite { // The monthly is GBP without a contribution [10, 0] // The annual is a AUD with contribution [160, 340] - // sub-without-LastChangeType is a EUR without a contribution [10, 0] + // sub-without-LastChangeType is a EUR without a contribution [6, 0] // ... is meant to ensure that we know how to extract the rate plan if it doesn't carry a LastChangeType. // The story with LastChangeTypes is // - present with "Add" : most recently added // - present with "Removed" : most recently removed // - not present : most recently added + // I also modified the base price from the original 10 to 6, to test the price capping. test("extracting `Supporter Plus V2` rate plan (monthly)") { val subscription = Fixtures.subscriptionFromJson("Migrations/SupporterPlus2024/monthly/subscription.json") @@ -197,7 +200,7 @@ class SupporterPlus2024MigrationTest extends munit.FunSuite { name = "Supporter Plus Monthly Charge", number = "C-05466358", currency = "EUR", - price = Some(10.0), + price = Some(6.0), billingPeriod = Some("Month"), chargedThroughDate = Some(LocalDate.of(2024, 9, 5)), processedThroughDate = Some(LocalDate.of(2024, 8, 5)), @@ -218,7 +221,7 @@ class SupporterPlus2024MigrationTest extends munit.FunSuite { name = "Contribution", number = "C-05466357", currency = "EUR", - price = Some(0.0), + price = Some(3.0), billingPeriod = Some("Month"), chargedThroughDate = Some(LocalDate.of(2024, 9, 5)), processedThroughDate = Some(LocalDate.of(2024, 8, 5)), @@ -248,6 +251,370 @@ class SupporterPlus2024MigrationTest extends munit.FunSuite { ) ) } + + test("extracting `Supporter Plus V2` rate plan base charge (monthly)") { + val subscription = Fixtures.subscriptionFromJson("Migrations/SupporterPlus2024/monthly/subscription.json") + assertEquals( + SupporterPlus2024Migration.supporterPlusBaseRatePlanCharge( + subscription.subscriptionNumber, + SupporterPlus2024Migration.supporterPlusV2RatePlan(subscription).toOption.get + ), + Right( + ZuoraRatePlanCharge( + productRatePlanChargeId = "8a128ed885fc6ded018602296af13eba", + name = "Supporter Plus Monthly Charge", + number = "C-04648407", + currency = "GBP", + price = Some(10.0), + billingPeriod = Some("Month"), + chargedThroughDate = Some(LocalDate.of(2024, 9, 30)), + processedThroughDate = Some(LocalDate.of(2024, 8, 30)), + specificBillingPeriod = None, + endDateCondition = Some("Subscription_End"), + upToPeriodsType = None, + upToPeriods = None, + billingDay = Some("ChargeTriggerDay"), + triggerEvent = Some("CustomerAcceptance"), + triggerDate = None, + discountPercentage = None, + originalOrderDate = Some(LocalDate.of(2023, 10, 1)), + effectiveStartDate = Some(LocalDate.of(2023, 9, 30)), + effectiveEndDate = Some(LocalDate.of(2025, 2, 27)) + ) + ) + ) + } + test("extracting `Supporter Plus V2` rate plan base charge (annual)") { + // The original subscription price is 160, the normal old price, + // but I edited the annual/subscription.json it to 150 to make sure we + // read the right price from the subscription. + val subscription = Fixtures.subscriptionFromJson("Migrations/SupporterPlus2024/annual/subscription.json") + assertEquals( + SupporterPlus2024Migration.supporterPlusBaseRatePlanCharge( + subscription.subscriptionNumber, + SupporterPlus2024Migration.supporterPlusV2RatePlan(subscription).toOption.get + ), + Right( + ZuoraRatePlanCharge( + productRatePlanChargeId = "8a128ed885fc6ded01860228f7cb3d5f", + name = "Supporter Plus Annual Charge", + number = "C-04819663", + currency = "AUD", + price = Some(150.0), + billingPeriod = Some("Annual"), + chargedThroughDate = Some(LocalDate.of(2024, 11, 11)), + processedThroughDate = Some(LocalDate.of(2023, 11, 11)), + specificBillingPeriod = None, + endDateCondition = Some("Subscription_End"), + upToPeriodsType = None, + upToPeriods = None, + billingDay = Some("ChargeTriggerDay"), + triggerEvent = Some("CustomerAcceptance"), + triggerDate = None, + discountPercentage = None, + originalOrderDate = Some(LocalDate.of(2023, 11, 26)), + effectiveStartDate = Some(LocalDate.of(2023, 11, 11)), + effectiveEndDate = Some(LocalDate.of(2024, 11, 11)) + ) + ) + ) + } + test("extracting `Supporter Plus V2` rate plan base charge (sub-without-LastChangeType)") { + // The original subscription price is 160, the normal old price, + // but I edited the annual/subscription.json it to 150 to make sure we + // read the right price from the subscription. + + val subscription = + Fixtures.subscriptionFromJson("Migrations/SupporterPlus2024/sub-without-LastChangeType/subscription.json") + assertEquals( + SupporterPlus2024Migration.supporterPlusBaseRatePlanCharge( + subscription.subscriptionNumber, + SupporterPlus2024Migration.supporterPlusV2RatePlan(subscription).toOption.get + ), + Right( + ZuoraRatePlanCharge( + productRatePlanChargeId = "8a128ed885fc6ded018602296af13eba", + name = "Supporter Plus Monthly Charge", + number = "C-05466358", + currency = "EUR", + price = Some(6.0), + billingPeriod = Some("Month"), + chargedThroughDate = Some(LocalDate.of(2024, 9, 5)), + processedThroughDate = Some(LocalDate.of(2024, 8, 5)), + specificBillingPeriod = None, + endDateCondition = Some("Subscription_End"), + upToPeriodsType = None, + upToPeriods = None, + billingDay = Some("ChargeTriggerDay"), + triggerEvent = Some("CustomerAcceptance"), + triggerDate = None, + discountPercentage = None, + originalOrderDate = Some(LocalDate.of(2024, 4, 5)), + effectiveStartDate = Some(LocalDate.of(2024, 4, 5)), + effectiveEndDate = Some(LocalDate.of(2025, 4, 5)) + ) + ) + ) + } + + test("extracting `Supporter Plus V2` rate plan contribution charge (monthly)") { + val subscription = Fixtures.subscriptionFromJson("Migrations/SupporterPlus2024/monthly/subscription.json") + assertEquals( + SupporterPlus2024Migration.supporterPlusContributionRatePlanCharge( + subscription.subscriptionNumber, + SupporterPlus2024Migration.supporterPlusV2RatePlan(subscription).toOption.get + ), + Right( + ZuoraRatePlanCharge( + productRatePlanChargeId = "8a128d7085fc6dec01860234cd075270", + name = "Contribution", + number = "C-04648406", + currency = "GBP", + price = Some(0.0), + billingPeriod = Some("Month"), + chargedThroughDate = Some(LocalDate.of(2024, 9, 30)), + processedThroughDate = Some(LocalDate.of(2024, 8, 30)), + specificBillingPeriod = None, + endDateCondition = Some("Subscription_End"), + upToPeriodsType = None, + upToPeriods = None, + billingDay = Some("ChargeTriggerDay"), + triggerEvent = Some("CustomerAcceptance"), + triggerDate = None, + discountPercentage = None, + originalOrderDate = Some(LocalDate.of(2023, 10, 1)), + effectiveStartDate = Some(LocalDate.of(2023, 9, 30)), + effectiveEndDate = Some(LocalDate.of(2025, 2, 27)) + ) + ) + ) + } + test("extracting `Supporter Plus V2` rate plan contribution charge (annual)") { + // The original subscription price is 160, the normal old price, + // but I edited the annual/subscription.json it to 150 to make sure we + // read the right price from the subscription. + val subscription = Fixtures.subscriptionFromJson("Migrations/SupporterPlus2024/annual/subscription.json") + assertEquals( + SupporterPlus2024Migration.supporterPlusContributionRatePlanCharge( + subscription.subscriptionNumber, + SupporterPlus2024Migration.supporterPlusV2RatePlan(subscription).toOption.get + ), + Right( + ZuoraRatePlanCharge( + productRatePlanChargeId = "8a12892d85fc6df4018602451322287f", + name = "Contribution", + number = "C-04819662", + currency = "AUD", + price = Some(340.0), + billingPeriod = Some("Annual"), + chargedThroughDate = Some(LocalDate.of(2024, 11, 11)), + processedThroughDate = Some(LocalDate.of(2023, 11, 11)), + specificBillingPeriod = None, + endDateCondition = Some("Subscription_End"), + upToPeriodsType = None, + upToPeriods = None, + billingDay = Some("ChargeTriggerDay"), + triggerEvent = Some("CustomerAcceptance"), + triggerDate = None, + discountPercentage = None, + originalOrderDate = Some(LocalDate.of(2023, 11, 26)), + effectiveStartDate = Some(LocalDate.of(2023, 11, 11)), + effectiveEndDate = Some(LocalDate.of(2024, 11, 11)) + ) + ) + ) + } + test("extracting `Supporter Plus V2` rate plan contribution charge (sub-without-LastChangeType)") { + // The original subscription price is 160, the normal old price, + // but I edited the annual/subscription.json it to 150 to make sure we + // read the right price from the subscription. + + val subscription = + Fixtures.subscriptionFromJson("Migrations/SupporterPlus2024/sub-without-LastChangeType/subscription.json") + assertEquals( + SupporterPlus2024Migration.supporterPlusContributionRatePlanCharge( + subscription.subscriptionNumber, + SupporterPlus2024Migration.supporterPlusV2RatePlan(subscription).toOption.get + ), + Right( + ZuoraRatePlanCharge( + productRatePlanChargeId = "8a128d7085fc6dec01860234cd075270", + name = "Contribution", + number = "C-05466357", + currency = "EUR", + price = Some(3.0), + billingPeriod = Some("Month"), + chargedThroughDate = Some(LocalDate.of(2024, 9, 5)), + processedThroughDate = Some(LocalDate.of(2024, 8, 5)), + specificBillingPeriod = None, + endDateCondition = Some("Subscription_End"), + upToPeriodsType = None, + upToPeriods = None, + billingDay = Some("ChargeTriggerDay"), + triggerEvent = Some("CustomerAcceptance"), + triggerDate = None, + discountPercentage = None, + originalOrderDate = Some(LocalDate.of(2024, 4, 5)), + effectiveStartDate = Some(LocalDate.of(2024, 4, 5)), + effectiveEndDate = Some(LocalDate.of(2025, 4, 5)) + ) + ) + ) + } + + test("sp2024_previous_base_amount (monthly)") { + val subscription = Fixtures.subscriptionFromJson("Migrations/SupporterPlus2024/monthly/subscription.json") + assertEquals( + SupporterPlus2024Migration.sp2024_previous_base_amount(subscription), + Right( + Some(BigDecimal(10)) + ) + ) + } + test("sp2024_previous_base_amount (annual)") { + val subscription = Fixtures.subscriptionFromJson("Migrations/SupporterPlus2024/annual/subscription.json") + assertEquals( + SupporterPlus2024Migration.sp2024_previous_base_amount(subscription), + Right( + Some(BigDecimal(150)) + ) + ) + } + test("sp2024_previous_base_amount (sub-without-LastChangeType)") { + val subscription = + Fixtures.subscriptionFromJson("Migrations/SupporterPlus2024/sub-without-LastChangeType/subscription.json") + assertEquals( + SupporterPlus2024Migration.sp2024_previous_base_amount(subscription), + Right( + Some(BigDecimal(6)) + ) + ) + } + + test("sp2024_new_base_amount (monthly)") { + val subscription = Fixtures.subscriptionFromJson("Migrations/SupporterPlus2024/monthly/subscription.json") + assertEquals( + SupporterPlus2024Migration.sp2024_new_base_amount(subscription), + Right( + Some(BigDecimal(12)) + ) + ) + } + test("sp2024_new_base_amount (annual)") { + val subscription = Fixtures.subscriptionFromJson("Migrations/SupporterPlus2024/annual/subscription.json") + assertEquals( + SupporterPlus2024Migration.sp2024_new_base_amount(subscription), + Right( + Some( + BigDecimal(150 * 1.27) + ) // the new price is 200, but it's too high (with the original 160, we would have been fine) + ) + ) + } + test("sp2024_new_base_amount (sub-without-LastChangeType)") { + val subscription = + Fixtures.subscriptionFromJson("Migrations/SupporterPlus2024/sub-without-LastChangeType/subscription.json") + // Below we make it explicit that we expect a 27% charge to be applied to the base charge as part of the price rise + val newBasePrice = 6 * 1.27 + assertEquals( + SupporterPlus2024Migration.sp2024_new_base_amount(subscription), + Right(Some(BigDecimal(newBasePrice))) + ) + } + + test("sp2024_contribution_amount (monthly)") { + val subscription = Fixtures.subscriptionFromJson("Migrations/SupporterPlus2024/monthly/subscription.json") + assertEquals( + SupporterPlus2024Migration.sp2024_contribution_amount(subscription), + Right( + Some(BigDecimal(0)) + ) + ) + } + test("sp2024_contribution_amount (annual)") { + val subscription = Fixtures.subscriptionFromJson("Migrations/SupporterPlus2024/annual/subscription.json") + assertEquals( + SupporterPlus2024Migration.sp2024_contribution_amount(subscription), + Right( + Some(BigDecimal(340)) + ) + ) + } + test("sp2024_contribution_amount (sub-without-LastChangeType)") { + val subscription = + Fixtures.subscriptionFromJson("Migrations/SupporterPlus2024/sub-without-LastChangeType/subscription.json") + assertEquals( + SupporterPlus2024Migration.sp2024_contribution_amount(subscription), + Right( + Some(BigDecimal(3)) + ) + ) + } + + test("sp2024_previous_combined_amount (monthly)") { + val subscription = Fixtures.subscriptionFromJson("Migrations/SupporterPlus2024/monthly/subscription.json") + assertEquals( + SupporterPlus2024Migration.sp2024_previous_combined_amount(subscription), + Right( + Some(BigDecimal(10)) + ) + ) + } + test("sp2024_previous_combined_amount (annual)") { + val subscription = Fixtures.subscriptionFromJson("Migrations/SupporterPlus2024/annual/subscription.json") + assertEquals( + SupporterPlus2024Migration.sp2024_previous_combined_amount(subscription), + Right( + Some(BigDecimal(490)) + ) + ) + } + test("sp2024_previous_combined_amount (sub-without-LastChangeType)") { + val subscription = + Fixtures.subscriptionFromJson("Migrations/SupporterPlus2024/sub-without-LastChangeType/subscription.json") + assertEquals( + SupporterPlus2024Migration.sp2024_previous_combined_amount(subscription), + Right( + Some(BigDecimal(6 + 3)) + ) + ) + } + + test("sp2024_new_combined_amount (monthly)") { + val subscription = Fixtures.subscriptionFromJson("Migrations/SupporterPlus2024/monthly/subscription.json") + assertEquals( + SupporterPlus2024Migration.sp2024_new_combined_amount(subscription), + Right( + Some(BigDecimal(12)) + ) + ) + } + test("sp2024_new_combined_amount (annual)") { + val subscription = Fixtures.subscriptionFromJson("Migrations/SupporterPlus2024/annual/subscription.json") + val newCombinedAmount = 150 * 1.27 + 340 + assertEquals( + SupporterPlus2024Migration.sp2024_new_combined_amount(subscription), + Right( + Some(BigDecimal(newCombinedAmount)) + ) + ) + } + test("sp2024_new_combined_amount (sub-without-LastChangeType)") { + val subscription = + Fixtures.subscriptionFromJson("Migrations/SupporterPlus2024/sub-without-LastChangeType/subscription.json") + + // Base price with a capping and the existing contribution + // And we are doing the rounding because floating point numbers are hard for computers. + val newCombinedAmount = BigDecimal(6 * 1.27 + 3).setScale(2, BigDecimal.RoundingMode.HALF_UP) + + assertEquals( + SupporterPlus2024Migration.sp2024_new_combined_amount(subscription), + Right( + Some(newCombinedAmount) + ) + ) + } + test("priceData (monthly)") { val subscription = Fixtures.subscriptionFromJson("Migrations/SupporterPlus2024/monthly/subscription.json") assertEquals( @@ -296,7 +663,6 @@ class SupporterPlus2024MigrationTest extends munit.FunSuite { ) ) } - test("EstimationResult (annual)") { val subscription = Fixtures.subscriptionFromJson("Migrations/SupporterPlus2024/annual/subscription.json") val invoices = Fixtures.invoiceListFromJson("Migrations/SupporterPlus2024/annual/invoices.json") @@ -323,6 +689,7 @@ class SupporterPlus2024MigrationTest extends munit.FunSuite { ) ) } + test("zuoraUpdate (monthly)") { val subscription = Fixtures.subscriptionFromJson("Migrations/SupporterPlus2024/monthly/subscription.json") assertEquals(