Skip to content

Commit

Permalink
Merge pull request #1087 from guardian/ph-20241022-1601-orders-amende…
Browse files Browse the repository at this point in the history
…ment

Orders Harmonization: Subscription Amendment
  • Loading branch information
shtukas authored Oct 31, 2024
2 parents eff247b + bdff7b9 commit 75a6516
Show file tree
Hide file tree
Showing 9 changed files with 879 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
11 changes: 11 additions & 0 deletions lambda/src/main/scala/pricemigrationengine/services/Zuora.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading

0 comments on commit 75a6516

Please sign in to comment.