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/migration-implementation-manual.md b/docs/migration-implementation-manual.md index be230429..4d1fb600 100644 --- a/docs/migration-implementation-manual.md +++ b/docs/migration-implementation-manual.md @@ -6,7 +6,7 @@ In this chapter, we explain how to implement a migration from scratch. In the chapter [Price migrations from first principles](https://github.com/guardian/price-migration-engine/blob/main/docs/price-migrations-from-first-principles.md) we have discovered why some commercial entities run price migrations. In this chapter I will be describing how we implement them at the Guardian. This description is almost entirely based on Pascal's experience in implementing and monitoring them, but if one day you are responsible for an implementation you may find easier to do things slightly differently. -Price migrations are not P&E projects. They are Marketing led projects. Marketing, in collaboration with Finance, will anounce that a collection of subscriptions in Zuora, often corresponding to all subscriptions of a particular product (for instance, Guardian Weekly subscriptions) will need to be migrated. The price migration engine orchestrates that operation from a systems point of view, but this happens within the larger context of operations ran by Marketing. +Price migrations are not P&E projects. They are Marketing led projects. Marketing, in collaboration with Finance, will announce that a collection of subscriptions in Zuora, often corresponding to all subscriptions of a particular product (for instance, Guardian Weekly subscriptions) will need to be migrated. The price migration engine orchestrates that operation from a systems point of view, but this happens within the larger context of operations ran by Marketing. ## The membership-workflow update diff --git a/docs/the-journey-of-a-cohort-item.md b/docs/the-journey-of-a-cohort-item.md index 88f9a82d..eb43dc8e 100644 --- a/docs/the-journey-of-a-cohort-item.md +++ b/docs/the-journey-of-a-cohort-item.md @@ -6,7 +6,7 @@ In [Price migrations from first principles](./price-migrations-from-first-princi In the engine parlance a "cohort" is a set of subscription numbers that are part of a given price migration. Logically a "cohort item" would then be one of those numbers, but in fact it refers to a record in the Dynamo table where the engine maintains information about the price migration. -One such cohort items seen in one of the Dynamo tables is shown below +One such cohort item seen in one of the Dynamo tables is shown below ![](./the-journey-of-a-cohort-item/1707822328.png) @@ -28,14 +28,9 @@ S-00000004 etc.. ``` -At this point we need to have decided a name for the migration. Let's imagine we called it Newspaper2024. We locate the `price-migration-engine-prod` S3 bucket and create a directory called `Newspaper2024` and put in it two files. +At this point we need to have decided a name for the migration. Let's imagine we called it Newspaper2024. We locate the `price-migration-engine-prod` S3 bucket and create a directory called `Newspaper2024` in which we put a file called `subscription-numbers.csv`. -- salesforce-subscription-id-report.csv , which contains the list of subscription numbers -- excluded-subscription-ids.csv , an empty file - -(Note: we are soon going to change those conventions, because they are legacy names which have outstayed their welcome, this will come as a PR and this documentation will be updated, but at the time those lines are written, that's the convention.) - -The engine will not automagically pick up those files, it needs to be instructed to do so (this will not be covered here), but the effect of the Dynamo being created and the file having been loaded is that we now have a table with records and each record simply contains a subscription number. +The engine will not automagically pick up the file, it needs to be instructed to do so (this will not be covered here), but the effect of the Dynamo table being created and the file having been loaded is that we now have a table with records and each record simply contains a subscription number. To help with understanding of the following steps let's use a (simplified) version of the CohortItem case class in the engine Scala Code @@ -140,13 +135,25 @@ Then, the cohort item is going to.... sleep. It's going to sleep as long as it t Today is now `LocalDate.of(2024, 5, 10)` minus about 40 days. The engine sees a subscription in `SalesforcePriceRiceCreationComplete` stage ready to be user notified. The engine is going to perform a certain number of look ups and will send a message to a queue that will eventually be delivered to Braze (triggering the sending of an email, or sending an additional request to an external company for a letter to be printed and delivered). -The message to the user is going to mention the start date and the new estimated new price. The engine then notifies Salesforce that the user has been notified (for record keeping) and then will perform the amendment in Zuora, meaning will update Zuora with the fact that the subscription in Zuora is price risen and that the price rise is taking effect on the date that had already been decided during the estimation step. Note that this is the first and only moment that the engine performs and write operation in Zuora during the entire price rise process of that subscription. +The message to the user is going to mention the start date and the new estimated new price. The outcome of this step is the subscription being put in processing stage `NotificationSendComplete`. + +Once the item is in `NotificationSendComplete` stage the SalesforceNotificationDateUpdatehandler lambda will fire up. This operation notifies Salesforce that the user has been notified (for record keeping) and puts the item in processing stage `NotificationSendDateWrittenToSalesforce`. + +The item now being in processing stage `NotificationSendDateWrittenToSalesforce` the engine will perform an amendment in Zuora, meaning will update Zuora with the fact that the subscription in Zuora is price risen and that the price rise is taking effect on the date that had already been decided during the Estimation step. Note that this is the first and only moment that the engine performs a write operation in Zuora during the entire price rise process of that subscription and puts the item in `AmendmentComplete`. -It is also important to notice that the amendment step only happened after the user notification step. This is to avoid a situation where there would be a bug in the engine or even just a long outage and causing subscriptions to be price risen in Zuora without the users having (yet) been notified. That would be illegal and put the Guardian in hot water. +The next step is yet another Salesforce update, where we inform Salesforce that the subscription in Zuora has been edited for price rise. Then the processing stage becomes `AmendmentWrittenToSalesforce`. This completes the price rise of subscription "S-00000003". -At this point the processing stage is `AmendmentComplete`. +It is also important to notice that the amendment step only happened after the user notification step. This is a security to avoid a situation where there would be a bug in the engine or even just a long outage, causing subscriptions to be price risen in Zuora without the users having (yet) been notified. That would be illegal and put the Guardian in hot water. -The last step is now another Salesforce update, where we inform Salesforce that the subscription in Zuora has been edited for price rise. Then the processing stage becomes `AmendmentWrittenToSalesforce`. This completes the price rise of subscription "S-00000003". +Last, but not least, this entire section, eg: moving through + +- `SalesforcePriceRiceCreationComplete` +- `NotificationSendComplete` +- `NotificationSendDateWrittenToSalesforce` +- `AmendmentComplete` +- `AmendmentWrittenToSalesforce` + +happens the same day. Being independent steps it is possible to delay the next one of the sequence, but there is also value in letting in them complete the same day, so that is a customer calls a CRS, they see, in Zuora and Salesforce, an up-to-date view of what the engine was in the process of doing. ### Cancellations @@ -163,3 +170,16 @@ CohortItem( Once the cohort item is in `Cancelled` state the engine will no longer touch it. Alike `AmendmentWrittenToSalesforce`, `Cancelled` is a final state for a cohort item. +### Processing Lambdas + +The price migration engine define a state machine which is linear. The lambdas fire in a given linear order (ocassionaly the same lambda fires more than once) For reference here is the other in which the lambda fire + +- CohortTableCreationHandler +- SubscriptionIdUploadHandler +- EstimationHandler +- SalesforcePriceRiseCreationHandler +- NotificationHandler +- SalesforceNotificationDateUpdateHandler +- AmendmentHandler +- SalesforceAmendmentUpdateHandler +- CohortTableDatalakeExportHandler diff --git a/lambda/src/main/scala/pricemigrationengine/handlers/AmendmentHandler.scala b/lambda/src/main/scala/pricemigrationengine/handlers/AmendmentHandler.scala index 66018764..fa0fc896 100644 --- a/lambda/src/main/scala/pricemigrationengine/handlers/AmendmentHandler.scala +++ b/lambda/src/main/scala/pricemigrationengine/handlers/AmendmentHandler.scala @@ -111,6 +111,7 @@ object AmendmentHandler extends CohortHandler { case DigiSubs2023 => true case Newspaper2024 => true case GW2024 => true + case SupporterPlus2024 => true case Legacy => true } } @@ -195,6 +196,13 @@ object AmendmentHandler extends CohortHandler { GW2024Migration.priceCap ) ) + case SupporterPlus2024 => + ZIO.fromEither( + SupporterPlus2024Migration.zuoraUpdate( + subscriptionBeforeUpdate, + startDate + ) + ) case Legacy => ZIO.fromEither( ZuoraSubscriptionUpdate @@ -249,14 +257,18 @@ object AmendmentHandler extends CohortHandler { } def handle(input: CohortSpec): ZIO[Logging, Failure, HandlerOutput] = { - main(input).provideSome[Logging]( - EnvConfig.cohortTable.layer, - EnvConfig.zuora.layer, - EnvConfig.stage.layer, - DynamoDBZIOLive.impl, - DynamoDBClientLive.impl, - CohortTableLive.impl(input), - ZuoraLive.impl - ) + MigrationType(input) match { + case SupporterPlus2024 => 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 + ) + } } } diff --git a/lambda/src/main/scala/pricemigrationengine/handlers/EstimationHandler.scala b/lambda/src/main/scala/pricemigrationengine/handlers/EstimationHandler.scala index 27dc8403..1bb30d71 100644 --- a/lambda/src/main/scala/pricemigrationengine/handlers/EstimationHandler.scala +++ b/lambda/src/main/scala/pricemigrationengine/handlers/EstimationHandler.scala @@ -1,6 +1,5 @@ package pricemigrationengine.handlers -import pricemigrationengine.migrations.newspaper2024Migration import pricemigrationengine.model.CohortTableFilter._ import pricemigrationengine.model._ import pricemigrationengine.services._ @@ -24,9 +23,14 @@ object EstimationHandler extends CohortHandler { catalogue <- Zuora.fetchProductCatalogue count <- CohortTable .fetch(ReadyForEstimation, None) + .filter(item => CohortItem.isProcessable(item)) .take(batchSize) .mapZIO(item => - estimate(catalogue, cohortSpec)(today, item).tapBoth(Logging.logFailure(item), Logging.logSuccess(item)) + if (Estimation1.isProcessable(item, today)) { + estimate(catalogue, cohortSpec)(today, item).tapBoth(Logging.logFailure(item), Logging.logSuccess(item)) + } else { + ZIO.succeed(()) + } ) .runCount .tapError(e => Logging.error(e.toString)) diff --git a/lambda/src/main/scala/pricemigrationengine/handlers/NotificationHandler.scala b/lambda/src/main/scala/pricemigrationengine/handlers/NotificationHandler.scala index 9f50fd37..1a118b7f 100644 --- a/lambda/src/main/scala/pricemigrationengine/handlers/NotificationHandler.scala +++ b/lambda/src/main/scala/pricemigrationengine/handlers/NotificationHandler.scala @@ -10,7 +10,8 @@ import pricemigrationengine.migrations.{ DigiSubs2023Migration, GW2024Migration, Membership2023Migration, - newspaper2024Migration + newspaper2024Migration, + SupporterPlus2024Migration, } import pricemigrationengine.model.RateplansProbe @@ -49,7 +50,30 @@ object NotificationHandler extends CohortHandler { count <- CohortTable .fetch(SalesforcePriceRiseCreationComplete, Some(today.plusDays(maxLeadTime(cohortSpec)))) .take(batchSize) - .mapZIO(item => sendNotification(cohortSpec)(item, today)) + .mapZIO(item => + MigrationType(cohortSpec) match { + case SupporterPlus2024 => { + for { + subscription <- Zuora.fetchSubscription(item.subscriptionName) + _ <- + if (SupporterPlus2024Migration.isUnderActiveCancellationSave(subscription, today)) { + CohortTable + .update( + CohortItem( + subscriptionName = item.subscriptionName, + processingStage = DoNotProcessUntil, + doNotProcessUntil = + Some(today.minusMonths(6)) // TODO: compute from the start of the cancellation, not today + ) + ) + } else { + sendNotification(cohortSpec)(item, today) + } + } yield () + } + case _ => sendNotification(cohortSpec)(item, today) + } + ) .runCount } yield HandlerOutput(isComplete = count < batchSize) } @@ -86,7 +110,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) @@ -113,10 +137,27 @@ object NotificationHandler extends CohortHandler { case Newspaper2024 => s"${currencySymbol}${estimatedNewPrice}" case GW2024 => s"${currencySymbol}${PriceCap.priceCapForNotification(oldPrice, estimatedNewPrice, GW2024Migration.priceCap)}" + case SupporterPlus2024 => s"${currencySymbol}${estimatedNewPrice}" } _ <- logMissingEmailAddress(cohortItem, contact) + // ---------------------------------------------------- + // Data for SupporterPlus2024 + subscription <- Zuora.fetchSubscription(cohortItem.subscriptionName) + sp2024ContributionAmountOpt <- ZIO.fromEither(sp2024ContributionAmount(cohortSpec, subscription)) + sp2024PreviousCombinedAmountOpt <- ZIO.fromEither(sp2024PreviousCombinedAmount(cohortSpec, subscription)) + sp2024NewCombinedAmountOpt <- ZIO.fromEither(sp2024NewCombinedAmount(cohortSpec, subscription)) + + sp2024ContributionAmountWithCurrencySymbolOpt = sp2024ContributionAmountOpt.map(a => s"${currencySymbol}${a}") + sp2024PreviousCombinedAmountWithCurrencySymbolOpt = sp2024PreviousCombinedAmountOpt.map(a => + s"${currencySymbol}${a}" + ) + sp2024NewCombinedAmountWithCurrencySymbolOpt = sp2024NewCombinedAmountOpt.map(a => s"${currencySymbol}${a}") + // ---------------------------------------------------- + + brazeName <- ZIO.fromEither(brazeName(cohortSpec, subscription)) + _ <- EmailSender.sendEmail( message = EmailMessage( EmailPayload( @@ -134,15 +175,28 @@ object NotificationHandler extends CohortHandler { billing_postal_code = postalCode, billing_state = address.state, billing_country = country, - payment_amount = priceWithOptionalCappingWithCurrencySymbol, + payment_amount = priceWithOptionalCappingWithCurrencySymbol, // [1] 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(""), + + // ------------------------------------------------------------- + // [1] + // (Comment group: 7992fa98) + // For SupporterPlus2024, we did not use that value. Instead we used the data provided by the + // extension below. That value was the new base price, but we needed a different data distribution + // to be able to fill the email template. That distribution is given by the next section. + + // SupporterPlus 2024 extension + sp2024_contribution_amount = sp2024ContributionAmountWithCurrencySymbolOpt.getOrElse(""), + sp2024_previous_combined_amount = sp2024PreviousCombinedAmountWithCurrencySymbolOpt.getOrElse(""), + sp2024_new_combined_amount = sp2024NewCombinedAmountWithCurrencySymbolOpt.getOrElse("") + // ------------------------------------------------------------- ) ) ), - cohortSpec.brazeCampaignName, + brazeName, contact.Id, contact.IdentityID__c ) @@ -223,6 +277,7 @@ object NotificationHandler extends CohortHandler { case DigiSubs2023 => DigiSubs2023Migration.maxLeadTime case Newspaper2024 => newspaper2024Migration.StaticData.maxLeadTime case GW2024 => GW2024Migration.maxLeadTime + case SupporterPlus2024 => SupporterPlus2024Migration.maxLeadTime case Legacy => 49 } } @@ -235,6 +290,7 @@ object NotificationHandler extends CohortHandler { case DigiSubs2023 => DigiSubs2023Migration.minLeadTime case Newspaper2024 => newspaper2024Migration.StaticData.minLeadTime case GW2024 => GW2024Migration.minLeadTime + case SupporterPlus2024 => SupporterPlus2024Migration.minLeadTime case Legacy => 35 } } @@ -281,7 +337,7 @@ object NotificationHandler extends CohortHandler { cohortSpec: CohortSpec, contact: SalesforceContact ): Either[NotificationHandlerFailure, SalesforceAddress] = { - def targetAddressMembership2023( + def testCompatibleEmptySalesforceAddress( contact: SalesforceContact ): Either[NotificationHandlerFailure, SalesforceAddress] = { (for { @@ -294,9 +350,10 @@ object NotificationHandler extends CohortHandler { } MigrationType(cohortSpec) match { - case DigiSubs2023 => Right(SalesforceAddress(Some(""), Some(""), Some(""), Some(""), Some(""))) - case Membership2023Monthlies => targetAddressMembership2023(contact) - case Membership2023Annuals => targetAddressMembership2023(contact) + case DigiSubs2023 => testCompatibleEmptySalesforceAddress(contact) + case Membership2023Monthlies => testCompatibleEmptySalesforceAddress(contact) + case Membership2023Annuals => testCompatibleEmptySalesforceAddress(contact) + case SupporterPlus2024 => testCompatibleEmptySalesforceAddress(contact) case _ => (for { billingAddress <- requiredField(contact.OtherAddress, "Contact.OtherAddress") @@ -420,10 +477,85 @@ object NotificationHandler extends CohortHandler { processingStage = Cancelled ) ) - _ <- notifySalesforceOfCancelledStatus(cohortSpec, cohortItem, reason) + // _ <- notifySalesforceOfCancelledStatus(cohortSpec, cohortItem, reason) + // Todo: get this back _ <- Logging.error( s"Subscription ${cohortItem.subscriptionName} has been cancelled, price rise notification not sent" ) } 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 sp2024ContributionAmount( + 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.contributionAmount(subscription).map(o => o.map(b => b.toString)) + case _ => Right(None) + } + } + + def sp2024PreviousCombinedAmount( + 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 + .previousCombinedAmount(subscription) + .map(o => o.map(b => b.toString)) + case _ => Right(None) + } + } + + def sp2024NewCombinedAmount( + 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.newCombinedAmount(subscription).map(o => o.map(b => b.toString)) + case _ => Right(None) + } + } + + // ------------------------------------------------------------------- + // Braze names + + // Note: + + // This function was introduced in September 2024, when as part of SupporterPlus2024 we integrated two different + // email templates in Braze to serve communication to the users. + + // Traditionally the name of the campaign or canvas has been part of the CohortSpec, making + // `cohortSpec.brazeCampaignName` the default carrier of this information, but in the case of SupporterPlus 2024 + // we have two canvases and need to decide one depending on the structure of the subscription. Once + // SupporterPlus2024 finished, we may decide to go back to a simpler format, or keep that function, depending + // on the likelihood of Marketing adopting this variation in the future. + + def brazeName(cohortSpec: CohortSpec, subscription: ZuoraSubscription): Either[Failure, String] = { + MigrationType(cohortSpec) match { + case SupporterPlus2024 => SupporterPlus2024Migration.brazeName(subscription) + case _ => Right(cohortSpec.brazeCampaignName) + } + } } diff --git a/lambda/src/main/scala/pricemigrationengine/handlers/SalesforceAmendmentUpdateHandler.scala b/lambda/src/main/scala/pricemigrationengine/handlers/SalesforceAmendmentUpdateHandler.scala index a3b26869..1491a57b 100644 --- a/lambda/src/main/scala/pricemigrationengine/handlers/SalesforceAmendmentUpdateHandler.scala +++ b/lambda/src/main/scala/pricemigrationengine/handlers/SalesforceAmendmentUpdateHandler.scala @@ -64,14 +64,19 @@ object SalesforceAmendmentUpdateHandler extends CohortHandler { ) .toRight(SalesforcePriceRiseWriteFailure(s"$cohortItem does not have a newSubscriptionId field")) - def handle(input: CohortSpec): ZIO[Logging, Failure, HandlerOutput] = - main(input).provideSome[Logging]( - EnvConfig.cohortTable.layer, - EnvConfig.salesforce.layer, - EnvConfig.stage.layer, - DynamoDBZIOLive.impl, - DynamoDBClientLive.impl, - CohortTableLive.impl(input), - SalesforceClientLive.impl - ) + def handle(input: CohortSpec): ZIO[Logging, Failure, HandlerOutput] = { + MigrationType(input) match { + case SupporterPlus2024 => ZIO.succeed(HandlerOutput(isComplete = true)) + case _ => + main(input).provideSome[Logging]( + EnvConfig.cohortTable.layer, + EnvConfig.salesforce.layer, + EnvConfig.stage.layer, + DynamoDBZIOLive.impl, + DynamoDBClientLive.impl, + CohortTableLive.impl(input), + SalesforceClientLive.impl + ) + } + } } diff --git a/lambda/src/main/scala/pricemigrationengine/handlers/SalesforceNotificationDateUpdateHandler.scala b/lambda/src/main/scala/pricemigrationengine/handlers/SalesforceNotificationDateUpdateHandler.scala index f2eec81a..93dc56c5 100644 --- a/lambda/src/main/scala/pricemigrationengine/handlers/SalesforceNotificationDateUpdateHandler.scala +++ b/lambda/src/main/scala/pricemigrationengine/handlers/SalesforceNotificationDateUpdateHandler.scala @@ -73,14 +73,19 @@ object SalesforceNotificationDateUpdateHandler extends CohortHandler { ) } - def handle(input: CohortSpec): ZIO[Logging, Failure, HandlerOutput] = - main(input).provideSome[Logging]( - EnvConfig.cohortTable.layer, - EnvConfig.salesforce.layer, - EnvConfig.stage.layer, - DynamoDBZIOLive.impl, - DynamoDBClientLive.impl, - CohortTableLive.impl(input), - SalesforceClientLive.impl - ) + def handle(input: CohortSpec): ZIO[Logging, Failure, HandlerOutput] = { + MigrationType(input) match { + case SupporterPlus2024 => ZIO.succeed(HandlerOutput(isComplete = true)) + case _ => + main(input).provideSome[Logging]( + EnvConfig.cohortTable.layer, + EnvConfig.salesforce.layer, + EnvConfig.stage.layer, + DynamoDBZIOLive.impl, + DynamoDBClientLive.impl, + CohortTableLive.impl(input), + SalesforceClientLive.impl + ) + } + } } diff --git a/lambda/src/main/scala/pricemigrationengine/handlers/SalesforcePriceRiseCreationHandler.scala b/lambda/src/main/scala/pricemigrationengine/handlers/SalesforcePriceRiseCreationHandler.scala index 17610142..86cbf344 100644 --- a/lambda/src/main/scala/pricemigrationengine/handlers/SalesforcePriceRiseCreationHandler.scala +++ b/lambda/src/main/scala/pricemigrationengine/handlers/SalesforcePriceRiseCreationHandler.scala @@ -88,8 +88,18 @@ object SalesforcePriceRiseCreationHandler extends CohortHandler { case DigiSubs2023 => estimatedNewPrice case Newspaper2024 => estimatedNewPrice case GW2024 => PriceCap.priceCapForNotification(oldPrice, estimatedNewPrice, GW2024Migration.priceCap) - case Legacy => PriceCap.priceCapLegacy(oldPrice, estimatedNewPrice) + case SupporterPlus2024 => estimatedNewPrice // [1] + case Legacy => PriceCap.priceCapLegacy(oldPrice, estimatedNewPrice) } + // [1] + // (Comment group: 7992fa98) + + // This value wasn't actually used because we did that step using a Ruby script (we did not run the + // SalesforcePriceRiseCreationHandler from the AWS step function). + // The problem was that from the CohortItem we only had the old base price and the new base price + // but not the contribution component, and therefore we could not compute the total which is what + // we need to send to Salesforce. + SalesforcePriceRise( Some(subscription.Name), Some(subscription.Buyer__c), @@ -105,14 +115,18 @@ object SalesforcePriceRiseCreationHandler extends CohortHandler { } def handle(input: CohortSpec): ZIO[Logging, Failure, HandlerOutput] = { - main(input).provideSome[Logging]( - EnvConfig.cohortTable.layer, - EnvConfig.salesforce.layer, - EnvConfig.stage.layer, - DynamoDBZIOLive.impl, - DynamoDBClientLive.impl, - CohortTableLive.impl(input), - SalesforceClientLive.impl - ) + MigrationType(input) match { + case SupporterPlus2024 => ZIO.succeed(HandlerOutput(isComplete = true)) + case _ => + main(input).provideSome[Logging]( + EnvConfig.cohortTable.layer, + EnvConfig.salesforce.layer, + EnvConfig.stage.layer, + DynamoDBZIOLive.impl, + DynamoDBClientLive.impl, + CohortTableLive.impl(input), + SalesforceClientLive.impl + ) + } } } diff --git a/lambda/src/main/scala/pricemigrationengine/handlers/SubscriptionIdUploadHandler.scala b/lambda/src/main/scala/pricemigrationengine/handlers/SubscriptionIdUploadHandler.scala index f844b754..b893fcac 100644 --- a/lambda/src/main/scala/pricemigrationengine/handlers/SubscriptionIdUploadHandler.scala +++ b/lambda/src/main/scala/pricemigrationengine/handlers/SubscriptionIdUploadHandler.scala @@ -88,7 +88,7 @@ object SubscriptionIdUploadHandler extends CohortHandler { .runCount } - def handle(input: CohortSpec): ZIO[Logging, Failure, HandlerOutput] = + def handle(input: CohortSpec): ZIO[Logging, Failure, HandlerOutput] = { main(input).provideSome[Logging]( EnvConfig.cohortTable.layer, EnvConfig.stage.layer, @@ -97,4 +97,5 @@ object SubscriptionIdUploadHandler extends CohortHandler { CohortTableLive.impl(input), S3Live.impl ) + } } diff --git a/lambda/src/main/scala/pricemigrationengine/model/AmendmentData.scala b/lambda/src/main/scala/pricemigrationengine/model/AmendmentData.scala index 138d422a..84952ef8 100644 --- a/lambda/src/main/scala/pricemigrationengine/model/AmendmentData.scala +++ b/lambda/src/main/scala/pricemigrationengine/model/AmendmentData.scala @@ -5,7 +5,8 @@ import pricemigrationengine.migrations.{ GuardianWeeklyMigration, Membership2023Migration, GW2024Migration, - newspaper2024Migration + newspaper2024Migration, + SupporterPlus2024Migration } import pricemigrationengine.model.ZuoraProductCatalogue.{homeDeliveryRatePlans, productPricingMap} @@ -316,19 +317,10 @@ object AmendmentData { nextServiceStartDate, cohortSpec ) - case DigiSubs2023 => - DigiSubs2023Migration.priceData( - subscription - ) - case Newspaper2024 => - newspaper2024Migration.Estimation.priceData( - subscription - ) - case GW2024 => - GW2024Migration.priceData( - subscription: ZuoraSubscription, - account: ZuoraAccount - ) + case DigiSubs2023 => DigiSubs2023Migration.priceData(subscription) + case Newspaper2024 => newspaper2024Migration.Estimation.priceData(subscription) + case GW2024 => GW2024Migration.priceData(subscription, account) + case SupporterPlus2024 => SupporterPlus2024Migration.priceData(subscription) case Legacy => priceDataWithRatePlanMatching(account, catalogue, subscription, invoiceList, nextServiceStartDate) } } diff --git a/lambda/src/main/scala/pricemigrationengine/model/BillingPeriod.scala b/lambda/src/main/scala/pricemigrationengine/model/BillingPeriod.scala index 4ba834fc..8bd9ad0d 100644 --- a/lambda/src/main/scala/pricemigrationengine/model/BillingPeriod.scala +++ b/lambda/src/main/scala/pricemigrationengine/model/BillingPeriod.scala @@ -13,11 +13,11 @@ object BillingPeriod { val notificationPaymentFrequencyMapping = Map( // This map is used to convert a CohortItem's billingPeriod in to the user friendly representation in letters // and emails. - "Month" -> "Monthly", - "Quarter" -> "Quarterly", - "Quarterly" -> "Quarterly", - "Semi_Annual" -> "Semiannually", - "Annual" -> "Annually" + "Month" -> "monthly", + "Quarter" -> "quarterly", + "Quarterly" -> "quarterly", + "Semi_Annual" -> "semiannually", + "Annual" -> "annually" ) def toString(period: BillingPeriod): String = { diff --git a/lambda/src/main/scala/pricemigrationengine/model/CohortItem.scala b/lambda/src/main/scala/pricemigrationengine/model/CohortItem.scala index b139bf6b..52d52fce 100644 --- a/lambda/src/main/scala/pricemigrationengine/model/CohortItem.scala +++ b/lambda/src/main/scala/pricemigrationengine/model/CohortItem.scala @@ -22,9 +22,22 @@ case class CohortItem( whenNotificationSent: Option[Instant] = None, whenNotificationSentWrittenToSalesforce: Option[Instant] = None, whenAmendmentWrittenToSalesforce: Option[Instant] = None, - cancellationReason: Option[String] = None + cancellationReason: Option[String] = None, + doNotProcessUntil: Option[LocalDate] = None // [18] ) +// [18] +// +// Date: July 2024 +// Author: Pascal +// comment group: 6157ec78 +// +// `doNotProcessUntil` was introduced in July 2024 as a simple way to support +// the "cancellation saves" feature that has been introduced this month and affecting the +// cancellation journey of Supporter Plus subscriptions. +// The default value is `None`, and if a none trivial value is present it represents +// the date until when the item should be left alone and not being processed. + object CohortItem { def fromSuccessfulEstimationResult(result: EstimationData): UIO[CohortItem] = @@ -69,4 +82,10 @@ object CohortItem { def fromExpiringSubscriptionResult(result: ExpiringSubscriptionResult): CohortItem = CohortItem(result.subscriptionNumber, Cancelled) + + def isProcessable(item: CohortItem): Boolean = { + // This function return a boolean indicating whether the item is processable + // defined as either doNotProcessUntil is None or is an instant in the past. + true + } } diff --git a/lambda/src/main/scala/pricemigrationengine/model/CohortTableFilter.scala b/lambda/src/main/scala/pricemigrationengine/model/CohortTableFilter.scala index e10e8a75..f48498e2 100644 --- a/lambda/src/main/scala/pricemigrationengine/model/CohortTableFilter.scala +++ b/lambda/src/main/scala/pricemigrationengine/model/CohortTableFilter.scala @@ -56,6 +56,8 @@ object CohortTableFilter { case object AmendmentFailed extends CohortTableFilter { override val value: String = "AmendmentFailed" } + case object DoNotProcessUntil extends CohortTableFilter { override val value: String = "DoNotProcessUntil" } + // Set of all states. Remember to update when adding a state. val all: Set[CohortTableFilter] = Set( AmendmentComplete, @@ -69,6 +71,7 @@ object CohortTableFilter { NotificationSendComplete, NotificationSendDateWrittenToSalesforce, ReadyForEstimation, - SalesforcePriceRiseCreationComplete + SalesforcePriceRiseCreationComplete, + DoNotProcessUntil, ) } diff --git a/lambda/src/main/scala/pricemigrationengine/model/MigrationType.scala b/lambda/src/main/scala/pricemigrationengine/model/MigrationType.scala index b064e473..3d96b68f 100644 --- a/lambda/src/main/scala/pricemigrationengine/model/MigrationType.scala +++ b/lambda/src/main/scala/pricemigrationengine/model/MigrationType.scala @@ -19,6 +19,7 @@ object SupporterPlus2023V1V2MA extends MigrationType object DigiSubs2023 extends MigrationType object Newspaper2024 extends MigrationType object GW2024 extends MigrationType +object SupporterPlus2024 extends MigrationType object MigrationType { def apply(cohortSpec: CohortSpec): MigrationType = cohortSpec.cohortName match { @@ -30,6 +31,7 @@ object MigrationType { case "DigiSubs2023_Batch2" => DigiSubs2023 case "Newspaper2024" => Newspaper2024 case "GW2024" => GW2024 + case "SupporterPlus2024" => SupporterPlus2024 case _ => Legacy } } 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..bece64a2 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: String = "", + sp2024_previous_combined_amount: String = "", + sp2024_new_combined_amount: String = "", ) +/* + 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/main/scala/pricemigrationengine/services/EmailSenderLive.scala b/lambda/src/main/scala/pricemigrationengine/services/EmailSenderLive.scala index c6be2136..02af1a33 100644 --- a/lambda/src/main/scala/pricemigrationengine/services/EmailSenderLive.scala +++ b/lambda/src/main/scala/pricemigrationengine/services/EmailSenderLive.scala @@ -60,7 +60,7 @@ object EmailSenderLive { ) } _ <- logging.info( - s"Successfully sent email for sfContactId ${message.SfContactId} message id: ${result.messageId}" + s"Successfully sent email for sfContactId ${message.SfContactId} message id: ${result.messageId}, message: ${message}" ) } yield () diff --git a/lambda/src/main/scala/pricemigrationengine/util/StartDates.scala b/lambda/src/main/scala/pricemigrationengine/util/StartDates.scala index 617021c7..f4f8f2b8 100644 --- a/lambda/src/main/scala/pricemigrationengine/util/StartDates.scala +++ b/lambda/src/main/scala/pricemigrationengine/util/StartDates.scala @@ -33,6 +33,7 @@ object StartDates { case Membership2023Annuals => None case DigiSubs2023 => None case Newspaper2024 => None + case SupporterPlus2024 => None case Legacy => None } } @@ -94,6 +95,7 @@ object StartDates { case SupporterPlus2023V1V2MA => 3 case DigiSubs2023 => 3 case GW2024 => 3 + case SupporterPlus2024 => 1 // no spread for S+2024 monthlies case Legacy => 3 } } else 1 @@ -115,6 +117,7 @@ object StartDates { case Membership2023Annuals => cohortSpecLowerBound(cohortSpec, today) case DigiSubs2023 => cohortSpecLowerBound(cohortSpec, today) case GW2024 => cohortSpecLowerBound(cohortSpec, today) + case SupporterPlus2024 => cohortSpecLowerBound(cohortSpec, today) case Legacy => cohortSpecLowerBound(cohortSpec, today) } diff --git a/lambda/src/test/scala/pricemigrationengine/handlers/NotificationHandlerTest.scala b/lambda/src/test/scala/pricemigrationengine/handlers/NotificationHandlerTest.scala index bc068d6e..383cd61d 100644 --- a/lambda/src/test/scala/pricemigrationengine/handlers/NotificationHandlerTest.scala +++ b/lambda/src/test/scala/pricemigrationengine/handlers/NotificationHandlerTest.scala @@ -23,7 +23,7 @@ class NotificationHandlerTest extends munit.FunSuite { private val startDateUserFriendlyFormat = "1 January 2020" private val currency = "GBP" private val billingPeriod = "Month" - private val billingPeriodInNotification = "Monthly" + private val billingPeriodInNotification = "monthly" // lowercase variation for the user communication private val oldPrice = BigDecimal(10.00) // The estimated new price is the price without cap diff --git a/lambda/src/test/scala/pricemigrationengine/model/PriceCap.scala b/lambda/src/test/scala/pricemigrationengine/model/PriceCap.scala deleted file mode 100644 index f3c6bdc6..00000000 --- a/lambda/src/test/scala/pricemigrationengine/model/PriceCap.scala +++ /dev/null @@ -1,182 +0,0 @@ -package pricemigrationengine.model - -import pricemigrationengine.model.PriceCap - -import java.time.LocalDate - -class LegacyMigrationsTest extends munit.FunSuite { - - test("The price legacy capping function works correctly") { - val oldPrice = BigDecimal(100) - val cappedPrice = BigDecimal(120) - val uncappedPrice = BigDecimal(156) - // Note the implicit price capping at 20% - assertEquals(cappedPrice, PriceCap.priceCapLegacy(oldPrice, uncappedPrice)) - } - - test("priceCapNotification (no need to apply)") { - val oldPrice = BigDecimal(100) - val newPrice = BigDecimal(110) - val cap = 1.25 - assertEquals( - PriceCap.priceCapForNotification(oldPrice, newPrice, cap), - BigDecimal(110) - ) - } - - test("priceCapNotification (need to apply)") { - val oldPrice = BigDecimal(100) - val newPrice = BigDecimal(250) - val cap = 1.25 - assertEquals( - PriceCap.priceCapForNotification(oldPrice, newPrice, cap), - BigDecimal(125) - ) - } - - test("priceCorrectionFactor (trivial case)") { - // The new price is lower than the old price multiplied by the price cap multiplier. - // We expect a correction factor equal to 1, for price invariance - assertEquals( - PriceCap.priceCorrectionFactor(BigDecimal(50), BigDecimal(55), BigDecimal(1.2)), - 1.0 - ) - } - - test("priceCorrectionFactor") { - // The capped price is 50 * 1.2 = 60 - // The new price is 120, which wee need to multiply by 0.5 to get to the capped price - // Therefore the price correction factor is 0.5 - assertEquals( - PriceCap.priceCorrectionFactor(BigDecimal(50), BigDecimal(120), BigDecimal(1.2)), - 0.5 - ) - } - - test("updateChargeOverride") { - val chargeOverride = ChargeOverride("productRatePlanChargeId", "Monthly", BigDecimal(200)) - val correctionFactor = 0.9 - assertEquals( - PriceCap.updateChargeOverride(chargeOverride, correctionFactor), - ChargeOverride("productRatePlanChargeId", "Monthly", BigDecimal(180)) - ) - } - - test("updateAddZuoraRatePlan (no ChargeOverrides)") { - val chargeOverride = ChargeOverride("productRatePlanChargeId", "Monthly", BigDecimal(200)) - val addZuoraRatePlan = AddZuoraRatePlan("productRatePlanId", LocalDate.of(2024, 3, 11), Nil) - val correctionFactor = 0.9 - - // We do not actually expect any change here, because the chargeOverrides are not specified - // This would be a mistake in the migration code, but nothing to do with the updateAddZuoraRatePlan - // function itself which must be invariant in this case - - assertEquals( - PriceCap.updateAddZuoraRatePlan(addZuoraRatePlan, correctionFactor), - addZuoraRatePlan - ) - } - - test("updateAddZuoraRatePlan (with two)") { - val chargeOverride1 = ChargeOverride("productRatePlanChargeId", "Monthly", BigDecimal(200)) - val chargeOverride2 = ChargeOverride("productRatePlanChargeId", "Monthly", BigDecimal(100)) - val addZuoraRatePlan = - AddZuoraRatePlan("productRatePlanId", LocalDate.of(2024, 3, 11), List(chargeOverride1, chargeOverride2)) - val correctionFactor = 0.9 - - assertEquals( - PriceCap.updateAddZuoraRatePlan(addZuoraRatePlan, correctionFactor), - AddZuoraRatePlan( - "productRatePlanId", - LocalDate.of(2024, 3, 11), - List( - ChargeOverride("productRatePlanChargeId", "Monthly", BigDecimal(180)), - ChargeOverride("productRatePlanChargeId", "Monthly", BigDecimal(90)) - ) - ) - ) - } - - test("priceCapForAmendment (without correction)") { - - val chargeOverride1 = ChargeOverride("productRatePlanChargeId", "Monthly", BigDecimal(25)) - val chargeOverride2 = ChargeOverride("productRatePlanChargeId", "Monthly", BigDecimal(30)) - val addZuoraRatePlan = - AddZuoraRatePlan("productRatePlanId", LocalDate.of(2024, 3, 11), List(chargeOverride1, chargeOverride2)) - val zuoraUpdate = ZuoraSubscriptionUpdate( - add = List(addZuoraRatePlan), - remove = List(), - currentTerm = None, - currentTermPeriodType = None - ) - - // The charges in chargeOverride1, and chargeOverride2 were chosen to equal 55, the (uncapped) new price. - - val oldPrice = BigDecimal(50) - val newPrice = BigDecimal(55) - val cap = BigDecimal(1.2) - - // We expect a correction factor equal to 1, resulting in no change in the zuoraUpdate - - assertEquals( - PriceCap.priceCorrectionFactor(oldPrice, newPrice, cap), - 1.0 - ) - - // With a correction factor of 0.5, we have 45 and 15 as corrected prices in the charges - - assertEquals( - PriceCap.priceCapForAmendment(oldPrice, newPrice, cap, zuoraUpdate), - zuoraUpdate - ) - } - - test("priceCapForAmendment (with correction)") { - - val chargeOverride1 = ChargeOverride("productRatePlanChargeId", "Monthly", BigDecimal(90)) - val chargeOverride2 = ChargeOverride("productRatePlanChargeId", "Monthly", BigDecimal(30)) - val addZuoraRatePlan = - AddZuoraRatePlan("productRatePlanId", LocalDate.of(2024, 3, 11), List(chargeOverride1, chargeOverride2)) - val zuoraUpdate = ZuoraSubscriptionUpdate( - add = List(addZuoraRatePlan), - remove = List(), - currentTerm = None, - currentTermPeriodType = None - ) - - // The charges in chargeOverride1, and chargeOverride2 were chosen to equal 120, the (uncapped) new price. - - val oldPrice = BigDecimal(50) - val newPrice = BigDecimal(120) - val cap = BigDecimal(1.2) - - // We expect a correction factor equal to 0.5 - - assertEquals( - PriceCap.priceCorrectionFactor(oldPrice, newPrice, cap), - 0.5 - ) - - // With a correction factor of 0.5, we have 45 and 15 as corrected prices in the charges - - assertEquals( - PriceCap.priceCapForAmendment(oldPrice, newPrice, cap, zuoraUpdate), - ZuoraSubscriptionUpdate( - add = List( - AddZuoraRatePlan( - "productRatePlanId", - LocalDate.of(2024, 3, 11), - List( - ChargeOverride("productRatePlanChargeId", "Monthly", BigDecimal(45)), - ChargeOverride("productRatePlanChargeId", "Monthly", BigDecimal(15)) - ) - ) - ), - remove = List(), - currentTerm = None, - currentTermPeriodType = None - ) - ) - } - -}