diff --git a/lambda/src/main/scala/pricemigrationengine/handlers/AmendmentHandler.scala b/lambda/src/main/scala/pricemigrationengine/handlers/AmendmentHandler.scala index a4c2a31b..e11691b5 100644 --- a/lambda/src/main/scala/pricemigrationengine/handlers/AmendmentHandler.scala +++ b/lambda/src/main/scala/pricemigrationengine/handlers/AmendmentHandler.scala @@ -134,21 +134,24 @@ object AmendmentHandler extends CohortHandler { _ <- renewSubscription(subscriptionBeforeUpdate, subscriptionBeforeUpdate.termEndDate, account) - update <- ZIO.fromEither( - SupporterPlus2024Migration.zuoraUpdate( - subscriptionBeforeUpdate, - startDate, - oldPrice, - estimatedNewPrice, - SupporterPlus2024Migration.priceCap + order <- ZIO.fromEither( + SupporterPlus2024Migration.amendmentOrderPayload( + orderDate = LocalDate.now(), + accountNumber = account.basicInfo.accountNumber, + subscriptionNumber = subscriptionBeforeUpdate.subscriptionNumber, + effectDate = startDate, + subscription = subscriptionBeforeUpdate, + oldPrice = oldPrice, + estimatedNewPrice = estimatedNewPrice, + priceCap = SupporterPlus2024Migration.priceCap ) ) _ <- Logging.info( - s"Amending subscription ${subscriptionBeforeUpdate.subscriptionNumber} with update ${update}" + s"Amending subscription ${subscriptionBeforeUpdate.subscriptionNumber} with order ${order}" ) - newSubscriptionId <- Zuora.updateSubscription(subscriptionBeforeUpdate, update) + _ <- Zuora.applyAmendmentOrder(subscriptionBeforeUpdate, order) subscriptionAfterUpdate <- fetchSubscription(item) diff --git a/lambda/src/main/scala/pricemigrationengine/migrations/SupporterPlus2024Migration.scala b/lambda/src/main/scala/pricemigrationengine/migrations/SupporterPlus2024Migration.scala index 4ae14217..c29cc07b 100644 --- a/lambda/src/main/scala/pricemigrationengine/migrations/SupporterPlus2024Migration.scala +++ b/lambda/src/main/scala/pricemigrationengine/migrations/SupporterPlus2024Migration.scala @@ -323,4 +323,102 @@ object SupporterPlus2024Migration { ) } } + + // ------------------------------------------------ + // Orders API Payloads + // ------------------------------------------------ + + // The two function below have same names but different signatures. + // The second one calls the first one. + + def amendmentOrdersPayload( + orderDate: LocalDate, + accountNumber: String, + subscriptionNumber: String, + effectDate: LocalDate, + removeRatePlanId: String, + productRatePlanId: String, + existingBaseProductRatePlanChargeId: String, + existingContributionRatePlanChargeId: String, + newBaseAmount: BigDecimal, + newContributionAmount: BigDecimal + ): ZuoraAmendmentOrderPayload = { + val triggerDates = List( + ZuoraAmendmentOrderPayloadOrderActionTriggerDate("ContractEffective", effectDate), + ZuoraAmendmentOrderPayloadOrderActionTriggerDate("ServiceActivation", effectDate), + ZuoraAmendmentOrderPayloadOrderActionTriggerDate("CustomerAcceptance", effectDate) + ) + val actionRemove = ZuoraAmendmentOrderPayloadOrderActionRemove( + `type` = "RemoveProduct", + triggerDates = triggerDates, + removeProduct = ZuoraAmendmentOrderPayloadOrderActionRemoveProduct(removeRatePlanId) + ) + val actionAdd = ZuoraAmendmentOrderPayloadOrderActionAdd( + `type` = "AddProduct", + triggerDates = triggerDates, + addProduct = ZuoraAmendmentOrderPayloadOrderActionAddProduct( + productRatePlanId = productRatePlanId, + chargeOverrides = List( + ZuoraAmendmentOrderPayloadOrderActionAddProductChargeOverride( + productRatePlanChargeId = existingBaseProductRatePlanChargeId, + pricing = Map("recurringFlatFee" -> Map("listPrice" -> newBaseAmount)) + ), + ZuoraAmendmentOrderPayloadOrderActionAddProductChargeOverride( + productRatePlanChargeId = existingContributionRatePlanChargeId, + pricing = Map("recurringFlatFee" -> Map("listPrice" -> newContributionAmount)) + ) + ) + ) + ) + val orderActions = List(actionRemove, actionAdd) + val subscriptions = List( + ZuoraAmendmentOrderPayloadSubscription(subscriptionNumber, orderActions) + ) + val processingOptions = ZuoraAmendmentOrderPayloadProcessingOptions(runBilling = false, collectPayment = false) + ZuoraAmendmentOrderPayload( + orderDate = orderDate, + existingAccountNumber = accountNumber, + subscriptions = subscriptions, + processingOptions = processingOptions + ) + } + + def amendmentOrderPayload( + orderDate: LocalDate, + accountNumber: String, + subscriptionNumber: String, + effectDate: LocalDate, + subscription: ZuoraSubscription, + oldPrice: BigDecimal, + estimatedNewPrice: BigDecimal, + priceCap: BigDecimal + ): Either[Failure, ZuoraAmendmentOrderPayload] = { + for { + existingRatePlan <- getSupporterPlusV2RatePlan(subscription) + existingBaseRatePlanCharge <- getSupporterPlusBaseRatePlanCharge( + subscription.subscriptionNumber, + existingRatePlan + ) + existingContributionRatePlanCharge <- getSupporterPlusContributionRatePlanCharge( + subscription.subscriptionNumber, + existingRatePlan + ) + existingContributionPrice <- existingContributionRatePlanCharge.price.toRight( + DataExtractionFailure( + s"[e4e702b6] Could not extract existing contribution price for subscription ${subscription.subscriptionNumber}" + ) + ) + } yield amendmentOrdersPayload( + orderDate = orderDate, + accountNumber = accountNumber, + subscriptionNumber = subscriptionNumber, + effectDate = effectDate, + removeRatePlanId = existingRatePlan.id, + productRatePlanId = existingRatePlan.productRatePlanId, + existingBaseProductRatePlanChargeId = existingBaseRatePlanCharge.productRatePlanChargeId, + existingContributionRatePlanChargeId = existingContributionRatePlanCharge.productRatePlanChargeId, + newBaseAmount = PriceCap.priceCapForNotification(oldPrice, estimatedNewPrice, priceCap), + newContributionAmount = existingContributionPrice + ) + } } diff --git a/lambda/src/main/scala/pricemigrationengine/model/Failure.scala b/lambda/src/main/scala/pricemigrationengine/model/Failure.scala index 7d3d5a69..95a5e005 100644 --- a/lambda/src/main/scala/pricemigrationengine/model/Failure.scala +++ b/lambda/src/main/scala/pricemigrationengine/model/Failure.scala @@ -25,6 +25,7 @@ case class ZuoraFailure(reason: String) extends Failure case class ZuoraFetchFailure(reason: String) extends Failure case class ZuoraUpdateFailure(reason: String) extends Failure case class ZuoraRenewalFailure(reason: String) extends Failure +case class ZuoraOrderFailure(reason: String) extends Failure case class CancelledSubscriptionFailure(reason: String) extends Failure case class ExpiringSubscriptionFailure(reason: String) extends Failure diff --git a/lambda/src/main/scala/pricemigrationengine/model/ZuoraAmendmentOrderPayload.scala b/lambda/src/main/scala/pricemigrationengine/model/ZuoraAmendmentOrderPayload.scala new file mode 100644 index 00000000..28c727e8 --- /dev/null +++ b/lambda/src/main/scala/pricemigrationengine/model/ZuoraAmendmentOrderPayload.scala @@ -0,0 +1,199 @@ +package pricemigrationengine.model + +import java.time.LocalDate +import upickle.default._ + +/* + Author: Pascal + Comment id: 4eb4b0a9 + + This file was written in October 2024 when we introduced Zuora.applyAmendmentOrder with the aim + of eventually decommissioning Zuora.updateSubscription. + + The documentation for this Order is available here: + Zuora documentation: https://knowledgecenter.zuora.com/Zuora_Billing/Manage_subscription_transactions/Orders/Order_actions_tutorials/D_Replace_a_product_in_a_subscription + + The migration to the new Orders API is currently a work in progress and the choice of the + model hierarchy in this file reflects the fact that we are migrating SupporterPlus2024 + from the old API to the new Orders API; in other words migrating the amendment a + SupporterPlus2024 subscription from using Zuora.updateSubscription to using Zuora.applyAmendmentOrder + */ + +/* + Author: Pascal + Comment id: cada56ad + + The model hierarchy presented in this file captures the making of a ZuoraAmendmentOrderPayload according + to the schema presented here: https://knowledgecenter.zuora.com/Zuora_Billing/Manage_subscription_transactions/Orders/Order_actions_tutorials/D_Replace_a_product_in_a_subscription + + One interesting note about the design is using a sealed trait to represent the different types of an Order action, + in the context of replacing a product in a subscription. Indeed, the two actions in that order are: + + { + "type": "RemoveProduct", + "triggerDates": [ + { + "name": "ContractEffective", + "triggerDate": "2024-11-28" + }, + { + "name": "ServiceActivation", + "triggerDate": "2024-11-28" + }, + { + "name": "CustomerAcceptance", + "triggerDate": "2024-11-28" + } + ], + "removeProduct": { + "ratePlanId": "8a12867e92c341870192c7c46bdb47d6" + } +} + +and + +{ + "type": "AddProduct", + "triggerDates": [ + { + "name": "ContractEffective", + "triggerDate": "2024-11-28" + }, + { + "name": "ServiceActivation", + "triggerDate": "2024-11-28" + }, + { + "name": "CustomerAcceptance", + "triggerDate": "2024-11-28" + } + ], + "addProduct": { + "productRatePlanId": "8a128ed885fc6ded018602296ace3eb8", + "chargeOverrides": [ + { + "productRatePlanChargeId": "8a128ed885fc6ded018602296af13eba", + "pricing": { + "recurringFlatFee": { + "listPrice": 12 + } + } + }, + { + "productRatePlanChargeId": "8a128d7085fc6dec01860234cd075270", + "pricing": { + "recurringFlatFee": { + "listPrice": 0 + } + } + } + ] + } +} + +They are respectively modeled as + +ZuoraAmendmentOrderPayloadOrderActionRemove +and +ZuoraAmendmentOrderPayloadOrderActionAdd + +Because of the way upickle works, we end up with a JSON serialization that has a "$type" field which is a bit annoying. +We can see it in the test data in SupporterPlus2024MigrationTest. + +I have tried to submit the Orders payload to Zuora with the "$type" field left inside the JSON but +Zuora errors during the processing. (Technically, it should actually work, but I think that Zuora may be doing payload introspection +beyond the mandatory fields and is error'ing on the "$type" field) + +I have tried to remove the "$type" field by providing a custom writer for the serialization of two classes, but +could not get that to work. + +In the end the solution I have chosen was to remove the "$type" field from the JSON before sending it to Zuora, +which explains the function type_flush in the ZuoraLive implementation of applyAmendmentOrder + +This is, for all intent and purpose a hack and in a future change we may try and get those +custom writers to work to avoid string manipulation in the implementation of applyAmendmentOrder. + + */ + +sealed trait ZuoraAmendmentOrderPayloadOrderAction +object ZuoraAmendmentOrderPayloadOrderAction { + implicit val w: Writer[ZuoraAmendmentOrderPayloadOrderAction] = Writer.merge( + macroW[ZuoraAmendmentOrderPayloadOrderActionAdd], + macroW[ZuoraAmendmentOrderPayloadOrderActionRemove] + ) +} +case class ZuoraAmendmentOrderPayloadOrderActionTriggerDate(name: String, triggerDate: LocalDate) +object ZuoraAmendmentOrderPayloadOrderActionTriggerDate { + implicit val w: Writer[ZuoraAmendmentOrderPayloadOrderActionTriggerDate] = macroW +} + +case class ZuoraAmendmentOrderPayloadOrderActionRemoveProduct(ratePlanId: String) +object ZuoraAmendmentOrderPayloadOrderActionRemoveProduct { + implicit val w: Writer[ZuoraAmendmentOrderPayloadOrderActionRemoveProduct] = macroW +} + +case class ZuoraAmendmentOrderPayloadOrderActionRemove( + `type`: String, + triggerDates: List[ZuoraAmendmentOrderPayloadOrderActionTriggerDate], + removeProduct: ZuoraAmendmentOrderPayloadOrderActionRemoveProduct +) extends ZuoraAmendmentOrderPayloadOrderAction +object ZuoraAmendmentOrderPayloadOrderActionRemove { + implicit val w: Writer[ZuoraAmendmentOrderPayloadOrderActionRemove] = macroW +} + +case class ZuoraAmendmentOrderPayloadOrderActionAddProductChargeOverride( + productRatePlanChargeId: String, + pricing: Map[String, Map[String, BigDecimal]] +) +object ZuoraAmendmentOrderPayloadOrderActionAddProductChargeOverride { + implicit val w: Writer[ZuoraAmendmentOrderPayloadOrderActionAddProductChargeOverride] = macroW +} + +case class ZuoraAmendmentOrderPayloadOrderActionAddProduct( + productRatePlanId: String, + chargeOverrides: List[ZuoraAmendmentOrderPayloadOrderActionAddProductChargeOverride] +) +object ZuoraAmendmentOrderPayloadOrderActionAddProduct { + implicit val w: Writer[ZuoraAmendmentOrderPayloadOrderActionAddProduct] = macroW +} + +case class ZuoraAmendmentOrderPayloadOrderActionAdd( + `type`: String, + triggerDates: List[ZuoraAmendmentOrderPayloadOrderActionTriggerDate], + addProduct: ZuoraAmendmentOrderPayloadOrderActionAddProduct +) extends ZuoraAmendmentOrderPayloadOrderAction +object ZuoraAmendmentOrderPayloadOrderActionAdd { + implicit val w: Writer[ZuoraAmendmentOrderPayloadOrderActionAdd] = macroW +} + +case class ZuoraAmendmentOrderPayloadSubscription( + subscriptionNumber: String, + orderActions: List[ZuoraAmendmentOrderPayloadOrderAction] +) +object ZuoraAmendmentOrderPayloadSubscription { + implicit val w: Writer[ZuoraAmendmentOrderPayloadSubscription] = macroW +} + +case class ZuoraAmendmentOrderPayloadProcessingOptions(runBilling: Boolean, collectPayment: Boolean) +object ZuoraAmendmentOrderPayloadProcessingOptions { + implicit val w: Writer[ZuoraAmendmentOrderPayloadProcessingOptions] = macroW +} + +case class ZuoraAmendmentOrderPayload( + orderDate: LocalDate, + existingAccountNumber: String, + subscriptions: List[ZuoraAmendmentOrderPayloadSubscription], + processingOptions: ZuoraAmendmentOrderPayloadProcessingOptions +) +object ZuoraAmendmentOrderPayload { + implicit val w: Writer[ZuoraAmendmentOrderPayload] = macroW +} + +case class ZuoraAmendmentOrderResponse( + success: Boolean + // Be careful if you are considering extending this class because the answer's shape + // varies depending on whether the operation was successful or not. +) +object ZuoraAmendmentOrderResponse { + implicit val rw: ReadWriter[ZuoraAmendmentOrderResponse] = macroRW +} diff --git a/lambda/src/main/scala/pricemigrationengine/services/Zuora.scala b/lambda/src/main/scala/pricemigrationengine/services/Zuora.scala index 93b5e2ec..13da922c 100644 --- a/lambda/src/main/scala/pricemigrationengine/services/Zuora.scala +++ b/lambda/src/main/scala/pricemigrationengine/services/Zuora.scala @@ -20,6 +20,11 @@ trait Zuora { update: ZuoraSubscriptionUpdate ): ZIO[Any, ZuoraUpdateFailure, ZuoraSubscriptionId] + def applyAmendmentOrder( + subscription: ZuoraSubscription, + payload: ZuoraAmendmentOrderPayload + ): ZIO[Any, ZuoraOrderFailure, Unit] + def renewSubscription( subscriptionNumber: String, payload: ZuoraRenewOrderPayload @@ -46,6 +51,12 @@ object Zuora { ): ZIO[Zuora, ZuoraUpdateFailure, ZuoraSubscriptionId] = ZIO.environmentWithZIO(_.get.updateSubscription(subscription, update)) + def applyAmendmentOrder( + subscription: ZuoraSubscription, + payload: ZuoraAmendmentOrderPayload + ): ZIO[Zuora, ZuoraOrderFailure, Unit] = + ZIO.environmentWithZIO(_.get.applyAmendmentOrder(subscription, payload)) + def renewSubscription( subscriptionNumber: String, payload: ZuoraRenewOrderPayload diff --git a/lambda/src/main/scala/pricemigrationengine/services/ZuoraLive.scala b/lambda/src/main/scala/pricemigrationengine/services/ZuoraLive.scala index 42937dc8..1fb199a3 100644 --- a/lambda/src/main/scala/pricemigrationengine/services/ZuoraLive.scala +++ b/lambda/src/main/scala/pricemigrationengine/services/ZuoraLive.scala @@ -208,11 +208,51 @@ object ZuoraLive { ) } + override def applyAmendmentOrder( + subscription: ZuoraSubscription, + payload: ZuoraAmendmentOrderPayload + ): ZIO[Any, ZuoraOrderFailure, Unit] = { + + // The existence of type_flush is explained in comment cada56ad. + // This is, for all intent and purpose a hack due to the way upickle deals with sealed traits + // and in a future change we will get rid of it, either by changing JSON library or coming up + // with the correct writers. + + def type_flush(str: String): String = { + str + .replace(""""$type":"ZuoraAmendmentOrderPayloadOrderActionAdd",""", "") + .replace(""""$type":"ZuoraAmendmentOrderPayloadOrderActionRemove",""", "") + } + + val body = type_flush(write(payload)) + + post[ZuoraAmendmentOrderResponse]( + path = s"orders", + body = type_flush(write(payload)) + ).foldZIO( + failure = e => + ZIO.fail( + ZuoraOrderFailure( + s"[f8569839] subscription number: ${subscription.subscriptionNumber}, payload: ${payload}, reason: ${e.reason}" + ) + ), + success = response => + if (response.success) { + ZIO.succeed(()) + } else { + ZIO.fail( + ZuoraOrderFailure( + s"[bb6f22ef] subscription number: ${subscription.subscriptionNumber}, payload: ${payload}, serialised payload: ${body}, with answer ${response}" + ) + ) + } + ) + } + override def renewSubscription( subscriptionNumber: String, payload: ZuoraRenewOrderPayload ): ZIO[Any, ZuoraRenewalFailure, Unit] = { - post[ZuoraRenewOrderResponse]( path = s"orders", body = write(payload) diff --git a/lambda/src/test/scala/pricemigrationengine/handlers/NotificationHandlerTest.scala b/lambda/src/test/scala/pricemigrationengine/handlers/NotificationHandlerTest.scala index 0f68a3d3..b1321205 100644 --- a/lambda/src/test/scala/pricemigrationengine/handlers/NotificationHandlerTest.scala +++ b/lambda/src/test/scala/pricemigrationengine/handlers/NotificationHandlerTest.scala @@ -163,6 +163,11 @@ class NotificationHandlerTest extends munit.FunSuite { update: ZuoraSubscriptionUpdate ): ZIO[Any, ZuoraUpdateFailure, ZuoraSubscriptionId] = ZIO.succeed("ZuoraSubscriptionId") + override def applyAmendmentOrder( + subscription: ZuoraSubscription, + payload: ZuoraAmendmentOrderPayload + ): ZIO[Any, ZuoraOrderFailure, Unit] = ZIO.succeed(()) + override def renewSubscription( subscriptionNumber: String, payload: ZuoraRenewOrderPayload diff --git a/lambda/src/test/scala/pricemigrationengine/migrations/SupporterPlus2024MigrationTest.scala b/lambda/src/test/scala/pricemigrationengine/migrations/SupporterPlus2024MigrationTest.scala index aa31f7a2..37b75184 100644 --- a/lambda/src/test/scala/pricemigrationengine/migrations/SupporterPlus2024MigrationTest.scala +++ b/lambda/src/test/scala/pricemigrationengine/migrations/SupporterPlus2024MigrationTest.scala @@ -1,10 +1,10 @@ package pricemigrationengine.migrations import pricemigrationengine.model._ +import upickle.default._ import java.time.LocalDate import pricemigrationengine.Fixtures -import pricemigrationengine.migrations.SupporterPlus2024Migration class SupporterPlus2024MigrationTest extends munit.FunSuite { @@ -859,4 +859,509 @@ class SupporterPlus2024MigrationTest extends munit.FunSuite { ) ) } + + // ----------------------------------- + // Orders API + + test("amendmentOrderPayload (monthly)") { + val subscription = Fixtures.subscriptionFromJson("Migrations/SupporterPlus2024/monthly/subscription.json") + assertEquals( + SupporterPlus2024Migration.amendmentOrderPayload( + orderDate = LocalDate.of(2024, 10, 25), + accountNumber = "74bff0f2", + subscriptionNumber = subscription.subscriptionNumber, + effectDate = LocalDate.of(2024, 11, 27), + subscription = subscription, + oldPrice = 10, + estimatedNewPrice = 12, + priceCap = 1.27 + ), + Right( + ZuoraAmendmentOrderPayload( + orderDate = LocalDate.of(2024, 10, 25), + existingAccountNumber = "74bff0f2", + subscriptions = List( + ZuoraAmendmentOrderPayloadSubscription( + subscriptionNumber = "SUBSCRIPTION-NUMBER", + orderActions = List( + ZuoraAmendmentOrderPayloadOrderActionRemove( + `type` = "RemoveProduct", + triggerDates = List( + ZuoraAmendmentOrderPayloadOrderActionTriggerDate( + name = "ContractEffective", + triggerDate = LocalDate.of(2024, 11, 27) + ), + ZuoraAmendmentOrderPayloadOrderActionTriggerDate( + name = "ServiceActivation", + triggerDate = LocalDate.of(2024, 11, 27) + ), + ZuoraAmendmentOrderPayloadOrderActionTriggerDate( + name = "CustomerAcceptance", + triggerDate = LocalDate.of(2024, 11, 27) + ) + ), + removeProduct = ZuoraAmendmentOrderPayloadOrderActionRemoveProduct( + ratePlanId = "8a12908b8dd07f56018de8f4950923b8" + ) + ), + ZuoraAmendmentOrderPayloadOrderActionAdd( + `type` = "AddProduct", + triggerDates = List( + ZuoraAmendmentOrderPayloadOrderActionTriggerDate( + name = "ContractEffective", + triggerDate = LocalDate.of(2024, 11, 27) + ), + ZuoraAmendmentOrderPayloadOrderActionTriggerDate( + name = "ServiceActivation", + triggerDate = LocalDate.of(2024, 11, 27) + ), + ZuoraAmendmentOrderPayloadOrderActionTriggerDate( + name = "CustomerAcceptance", + triggerDate = LocalDate.of(2024, 11, 27) + ) + ), + addProduct = ZuoraAmendmentOrderPayloadOrderActionAddProduct( + productRatePlanId = "8a128ed885fc6ded018602296ace3eb8", + chargeOverrides = List( + ZuoraAmendmentOrderPayloadOrderActionAddProductChargeOverride( + productRatePlanChargeId = "8a128ed885fc6ded018602296af13eba", + pricing = Map( + "recurringFlatFee" -> Map( + "listPrice" -> 12 + ) + ) + ), + ZuoraAmendmentOrderPayloadOrderActionAddProductChargeOverride( + productRatePlanChargeId = "8a128d7085fc6dec01860234cd075270", + pricing = Map( + "recurringFlatFee" -> Map( + "listPrice" -> 0.0 + ) + ) + ) + ) + ) + ) + ) + ) + ), + processingOptions = ZuoraAmendmentOrderPayloadProcessingOptions(false, false) + ) + ) + ) + } + test("amendmentOrderPayload (monthly), with artificial cap") { + val subscription = Fixtures.subscriptionFromJson("Migrations/SupporterPlus2024/monthly/subscription.json") + assertEquals( + SupporterPlus2024Migration.amendmentOrderPayload( + orderDate = LocalDate.of(2024, 10, 25), + accountNumber = "74bff0f2", + subscriptionNumber = subscription.subscriptionNumber, + effectDate = LocalDate.of(2024, 11, 27), + subscription = subscription, + oldPrice = 10, + estimatedNewPrice = 12, + priceCap = 1.1 + ), + Right( + ZuoraAmendmentOrderPayload( + orderDate = LocalDate.of(2024, 10, 25), + existingAccountNumber = "74bff0f2", + subscriptions = List( + ZuoraAmendmentOrderPayloadSubscription( + subscriptionNumber = "SUBSCRIPTION-NUMBER", + orderActions = List( + ZuoraAmendmentOrderPayloadOrderActionRemove( + `type` = "RemoveProduct", + triggerDates = List( + ZuoraAmendmentOrderPayloadOrderActionTriggerDate( + name = "ContractEffective", + triggerDate = LocalDate.of(2024, 11, 27) + ), + ZuoraAmendmentOrderPayloadOrderActionTriggerDate( + name = "ServiceActivation", + triggerDate = LocalDate.of(2024, 11, 27) + ), + ZuoraAmendmentOrderPayloadOrderActionTriggerDate( + name = "CustomerAcceptance", + triggerDate = LocalDate.of(2024, 11, 27) + ) + ), + removeProduct = ZuoraAmendmentOrderPayloadOrderActionRemoveProduct( + ratePlanId = "8a12908b8dd07f56018de8f4950923b8" + ) + ), + ZuoraAmendmentOrderPayloadOrderActionAdd( + `type` = "AddProduct", + triggerDates = List( + ZuoraAmendmentOrderPayloadOrderActionTriggerDate( + name = "ContractEffective", + triggerDate = LocalDate.of(2024, 11, 27) + ), + ZuoraAmendmentOrderPayloadOrderActionTriggerDate( + name = "ServiceActivation", + triggerDate = LocalDate.of(2024, 11, 27) + ), + ZuoraAmendmentOrderPayloadOrderActionTriggerDate( + name = "CustomerAcceptance", + triggerDate = LocalDate.of(2024, 11, 27) + ) + ), + addProduct = ZuoraAmendmentOrderPayloadOrderActionAddProduct( + productRatePlanId = "8a128ed885fc6ded018602296ace3eb8", + chargeOverrides = List( + ZuoraAmendmentOrderPayloadOrderActionAddProductChargeOverride( + productRatePlanChargeId = "8a128ed885fc6ded018602296af13eba", + pricing = Map( + "recurringFlatFee" -> Map( + "listPrice" -> 11 + ) + ) + ), + ZuoraAmendmentOrderPayloadOrderActionAddProductChargeOverride( + productRatePlanChargeId = "8a128d7085fc6dec01860234cd075270", + pricing = Map( + "recurringFlatFee" -> Map( + "listPrice" -> 0.0 + ) + ) + ) + ) + ) + ) + ) + ) + ), + processingOptions = ZuoraAmendmentOrderPayloadProcessingOptions(false, false) + ) + ) + ) + } + test("amendmentOrderPayload (annual, with capping)") { + val subscription = Fixtures.subscriptionFromJson("Migrations/SupporterPlus2024/annual/subscription.json") + assertEquals( + SupporterPlus2024Migration.amendmentOrderPayload( + orderDate = LocalDate.of(2024, 9, 9), + accountNumber = "d4a7d0af", + subscriptionNumber = subscription.subscriptionNumber, + effectDate = LocalDate.of(2024, 10, 11), + subscription = subscription, + oldPrice = 150, + estimatedNewPrice = 200, + priceCap = 1.27 + ), + Right( + ZuoraAmendmentOrderPayload( + orderDate = LocalDate.of(2024, 9, 9), + existingAccountNumber = "d4a7d0af", + subscriptions = List( + ZuoraAmendmentOrderPayloadSubscription( + subscriptionNumber = "SUBSCRIPTION-NUMBER", + orderActions = List( + ZuoraAmendmentOrderPayloadOrderActionRemove( + `type` = "RemoveProduct", + triggerDates = List( + ZuoraAmendmentOrderPayloadOrderActionTriggerDate( + name = "ContractEffective", + triggerDate = LocalDate.of(2024, 10, 11) + ), + ZuoraAmendmentOrderPayloadOrderActionTriggerDate( + name = "ServiceActivation", + triggerDate = LocalDate.of(2024, 10, 11) + ), + ZuoraAmendmentOrderPayloadOrderActionTriggerDate( + name = "CustomerAcceptance", + triggerDate = LocalDate.of(2024, 10, 11) + ) + ), + removeProduct = ZuoraAmendmentOrderPayloadOrderActionRemoveProduct( + ratePlanId = "8a12820a8c0ff963018c2504ba045b2f" + ) + ), + ZuoraAmendmentOrderPayloadOrderActionAdd( + `type` = "AddProduct", + triggerDates = List( + ZuoraAmendmentOrderPayloadOrderActionTriggerDate( + name = "ContractEffective", + triggerDate = LocalDate.of(2024, 10, 11) + ), + ZuoraAmendmentOrderPayloadOrderActionTriggerDate( + name = "ServiceActivation", + triggerDate = LocalDate.of(2024, 10, 11) + ), + ZuoraAmendmentOrderPayloadOrderActionTriggerDate( + name = "CustomerAcceptance", + triggerDate = LocalDate.of(2024, 10, 11) + ) + ), + addProduct = ZuoraAmendmentOrderPayloadOrderActionAddProduct( + productRatePlanId = "8a128ed885fc6ded01860228f77e3d5a", + chargeOverrides = List( + ZuoraAmendmentOrderPayloadOrderActionAddProductChargeOverride( + productRatePlanChargeId = "8a128ed885fc6ded01860228f7cb3d5f", + pricing = Map( + "recurringFlatFee" -> Map( + "listPrice" -> 190.50 + ) + ) + ), + ZuoraAmendmentOrderPayloadOrderActionAddProductChargeOverride( + productRatePlanChargeId = "8a12892d85fc6df4018602451322287f", + pricing = Map( + "recurringFlatFee" -> Map( + "listPrice" -> 340.0 + ) + ) + ) + ) + ) + ) + ) + ) + ), + processingOptions = ZuoraAmendmentOrderPayloadProcessingOptions(false, false) + ) + ) + ) + } + test("Correct JSON serialisation for ZuoraAmendmentOrderPayloadOrderActionRemove") { + + // Here we are testing the natural serialization of the whole payload, by upickle. + // The purpose is to ensure that the model hierarchy leads to the same essential JSON structure. + // See comment cada56ad for more details on the structure and information about how we sanitize the JSON + // before we send it to Zuora. + + val data = ZuoraAmendmentOrderPayloadOrderActionRemove( + `type` = "RemoveProduct", + triggerDates = List( + ZuoraAmendmentOrderPayloadOrderActionTriggerDate( + name = "ContractEffective", + triggerDate = LocalDate.of(2024, 10, 11) + ), + ZuoraAmendmentOrderPayloadOrderActionTriggerDate( + name = "ServiceActivation", + triggerDate = LocalDate.of(2024, 10, 11) + ), + ZuoraAmendmentOrderPayloadOrderActionTriggerDate( + name = "CustomerAcceptance", + triggerDate = LocalDate.of(2024, 10, 11) + ) + ), + removeProduct = ZuoraAmendmentOrderPayloadOrderActionRemoveProduct( + ratePlanId = "8a12820a8c0ff963018c2504ba045b2f" + ) + ) + assertEquals( + write(data, indent = 2), + """{ + | "$type": "ZuoraAmendmentOrderPayloadOrderActionRemove", + | "type": "RemoveProduct", + | "triggerDates": [ + | { + | "name": "ContractEffective", + | "triggerDate": "2024-10-11" + | }, + | { + | "name": "ServiceActivation", + | "triggerDate": "2024-10-11" + | }, + | { + | "name": "CustomerAcceptance", + | "triggerDate": "2024-10-11" + | } + | ], + | "removeProduct": { + | "ratePlanId": "8a12820a8c0ff963018c2504ba045b2f" + | } + |}""".stripMargin + ) + } + test("Correct JSON serialisation for ZuoraAmendmentOrderPayloadOrderActionAdd") { + + // Here we are testing the natural serialization of the whole payload, by upickle. + // The purpose is to ensure that the model hierarchy leads to the same essential JSON structure. + // See comment cada56ad for more details on the structure and information about how we sanitize the JSON + // before we send it to Zuora. + + val data = ZuoraAmendmentOrderPayloadOrderActionAdd( + `type` = "AddProduct", + triggerDates = List( + ZuoraAmendmentOrderPayloadOrderActionTriggerDate( + name = "ContractEffective", + triggerDate = LocalDate.of(2024, 10, 11) + ), + ZuoraAmendmentOrderPayloadOrderActionTriggerDate( + name = "ServiceActivation", + triggerDate = LocalDate.of(2024, 10, 11) + ), + ZuoraAmendmentOrderPayloadOrderActionTriggerDate( + name = "CustomerAcceptance", + triggerDate = LocalDate.of(2024, 10, 11) + ) + ), + addProduct = ZuoraAmendmentOrderPayloadOrderActionAddProduct( + productRatePlanId = "8a128ed885fc6ded01860228f77e3d5a", + chargeOverrides = List( + ZuoraAmendmentOrderPayloadOrderActionAddProductChargeOverride( + productRatePlanChargeId = "8a128ed885fc6ded01860228f7cb3d5f", + pricing = Map( + "recurringFlatFee" -> Map( + "listPrice" -> 190.50 + ) + ) + ), + ZuoraAmendmentOrderPayloadOrderActionAddProductChargeOverride( + productRatePlanChargeId = "8a12892d85fc6df4018602451322287f", + pricing = Map( + "recurringFlatFee" -> Map( + "listPrice" -> 340.0 + ) + ) + ) + ) + ) + ) + assertEquals( + write(data, indent = 2), + """{ + | "$type": "ZuoraAmendmentOrderPayloadOrderActionAdd", + | "type": "AddProduct", + | "triggerDates": [ + | { + | "name": "ContractEffective", + | "triggerDate": "2024-10-11" + | }, + | { + | "name": "ServiceActivation", + | "triggerDate": "2024-10-11" + | }, + | { + | "name": "CustomerAcceptance", + | "triggerDate": "2024-10-11" + | } + | ], + | "addProduct": { + | "productRatePlanId": "8a128ed885fc6ded01860228f77e3d5a", + | "chargeOverrides": [ + | { + | "productRatePlanChargeId": "8a128ed885fc6ded01860228f7cb3d5f", + | "pricing": { + | "recurringFlatFee": { + | "listPrice": 190.5 + | } + | } + | }, + | { + | "productRatePlanChargeId": "8a12892d85fc6df4018602451322287f", + | "pricing": { + | "recurringFlatFee": { + | "listPrice": 340 + | } + | } + | } + | ] + | } + |}""".stripMargin + ) + } + test( + "Correct JSON serialisation for ZuoraAmendmentOrderPayload using SupporterPlus2024Migration.amendmentOrdersPayload" + ) { + + // Here we are testing the natural serialization of the whole payload, by upickle. + // The purpose is to ensure that the model hierarchy leads to the same essential JSON structure. + // See comment cada56ad for more details on the structure and information about how we sanitize the JSON + // before we send it to Zuora. + + val data = SupporterPlus2024Migration.amendmentOrdersPayload( + orderDate = LocalDate.of(2024, 10, 24), + accountNumber = "A01955911", + subscriptionNumber = "A-S02019224", + effectDate = LocalDate.of(2024, 11, 26), + removeRatePlanId = "8a128e208bdd4251018c0d5050970bd9", + productRatePlanId = "8a128ed885fc6ded018602296ace3eb8", + existingBaseProductRatePlanChargeId = "8a128ed885fc6ded018602296af13eba", + existingContributionRatePlanChargeId = "8a128d7085fc6dec01860234cd075270", + newBaseAmount = 15, + newContributionAmount = 0 + ) + assertEquals( + write(data, indent = 2), + """{ + | "orderDate": "2024-10-24", + | "existingAccountNumber": "A01955911", + | "subscriptions": [ + | { + | "subscriptionNumber": "A-S02019224", + | "orderActions": [ + | { + | "$type": "ZuoraAmendmentOrderPayloadOrderActionRemove", + | "type": "RemoveProduct", + | "triggerDates": [ + | { + | "name": "ContractEffective", + | "triggerDate": "2024-11-26" + | }, + | { + | "name": "ServiceActivation", + | "triggerDate": "2024-11-26" + | }, + | { + | "name": "CustomerAcceptance", + | "triggerDate": "2024-11-26" + | } + | ], + | "removeProduct": { + | "ratePlanId": "8a128e208bdd4251018c0d5050970bd9" + | } + | }, + | { + | "$type": "ZuoraAmendmentOrderPayloadOrderActionAdd", + | "type": "AddProduct", + | "triggerDates": [ + | { + | "name": "ContractEffective", + | "triggerDate": "2024-11-26" + | }, + | { + | "name": "ServiceActivation", + | "triggerDate": "2024-11-26" + | }, + | { + | "name": "CustomerAcceptance", + | "triggerDate": "2024-11-26" + | } + | ], + | "addProduct": { + | "productRatePlanId": "8a128ed885fc6ded018602296ace3eb8", + | "chargeOverrides": [ + | { + | "productRatePlanChargeId": "8a128ed885fc6ded018602296af13eba", + | "pricing": { + | "recurringFlatFee": { + | "listPrice": 15 + | } + | } + | }, + | { + | "productRatePlanChargeId": "8a128d7085fc6dec01860234cd075270", + | "pricing": { + | "recurringFlatFee": { + | "listPrice": 0 + | } + | } + | } + | ] + | } + | } + | ] + | } + | ], + | "processingOptions": { + | "runBilling": false, + | "collectPayment": false + | } + |}""".stripMargin + ) + } } diff --git a/lambda/src/test/scala/pricemigrationengine/service/MockZuora.scala b/lambda/src/test/scala/pricemigrationengine/service/MockZuora.scala index 16e39837..db077955 100644 --- a/lambda/src/test/scala/pricemigrationengine/service/MockZuora.scala +++ b/lambda/src/test/scala/pricemigrationengine/service/MockZuora.scala @@ -15,7 +15,7 @@ object MockZuora extends Mock[Zuora] { object FetchProductCatalogue extends Effect[Unit, ZuoraFetchFailure, ZuoraProductCatalogue] object UpdateSubscription extends Effect[(ZuoraSubscription, ZuoraSubscriptionUpdate), ZuoraUpdateFailure, ZuoraSubscriptionId] - + object ApplyAmendmentOrder extends Effect[(ZuoraSubscription, ZuoraAmendmentOrderPayload), ZuoraOrderFailure, Unit] object RenewSubscription extends Effect[String, ZuoraRenewalFailure, Unit] val compose: URLayer[Proxy, Zuora] = ZLayer.fromZIO(ZIO.service[Proxy].map { proxy => @@ -38,6 +38,11 @@ object MockZuora extends Mock[Zuora] { : ZIO[Any, ZuoraUpdateFailure, ZuoraSubscriptionId] = proxy(UpdateSubscription, subscription, update) + override def applyAmendmentOrder( + subscription: ZuoraSubscription, + payload: ZuoraAmendmentOrderPayload + ): ZIO[Any, ZuoraOrderFailure, Unit] = proxy(ApplyAmendmentOrder, subscription, payload) + override def renewSubscription(subscriptionNumber: String, payload: ZuoraRenewOrderPayload) : ZIO[Any, ZuoraRenewalFailure, Unit] = proxy(RenewSubscription, subscriptionNumber)