-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
17 changed files
with
37,029 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
# Coding Conventions | ||
|
||
The price migration engine doesn't have coding conventions per se. ZIO does a very good job at keeping sanity between pure and impure code, and putting adhoc code into migration specific objects (the so called "modern" migrations) helps separate the general engine logic from specific requests. We also rely on the coding expertise of contributors to do the right thing (including breaking rules when needed). | ||
|
||
With that said, we have the following conventions | ||
|
||
- Coding Directive #1: When using `MigrationType(cohortSpec)` to dispatch values or behaviour per migration, and unless exceptions (there are a couple in the code for when we handle exceptions or for exceptional circumstances), we will be explicit on what we want and declaring all the cases. If somebody is implementing a new migration and follows the steps Pascal presented during GW2024, then declaring a new case will happen during the [first step](https://github.com/guardian/price-migration-engine/pull/1012). The reason for this rule is that an inexperienced contributor could easily miss a place in the code where some thought should have been given on what a new migration should specify. If the code compiles without prompting that decision the contributor might miss it. And even if the decision is to go with the "default", this needs to be explicitly specified. This convention was introduced in this pull request [pull:1022](https://github.com/guardian/price-migration-engine/pull/1022). | ||
|
306 changes: 306 additions & 0 deletions
306
lambda/src/main/scala/pricemigrationengine/migrations/SupporterPlus2024Migration.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,306 @@ | ||
package pricemigrationengine.migrations | ||
import pricemigrationengine.model.ZuoraRatePlan | ||
import pricemigrationengine.model._ | ||
|
||
import java.time.LocalDate | ||
|
||
object SupporterPlus2024Migration { | ||
|
||
// ------------------------------------------------ | ||
// Static Data | ||
// ------------------------------------------------ | ||
|
||
val maxLeadTime = 33 | ||
val minLeadTime = 31 | ||
|
||
val pricesMonthlyOld: Map[String, Double] = Map( | ||
"GBP" -> 10, | ||
"USD" -> 13, | ||
"CAD" -> 13, | ||
"AUD" -> 17, | ||
"NZD" -> 17, | ||
"EUR" -> 10 | ||
) | ||
|
||
val pricesAnnualOld: Map[String, Double] = Map( | ||
"GBP" -> 95, | ||
"USD" -> 120, | ||
"CAD" -> 120, | ||
"AUD" -> 160, | ||
"NZD" -> 160, | ||
"EUR" -> 95 | ||
) | ||
|
||
val pricesMonthlyNew: Map[String, Double] = Map( | ||
"GBP" -> 12, | ||
"USD" -> 15, | ||
"CAD" -> 15, | ||
"AUD" -> 20, | ||
"NZD" -> 20, | ||
"EUR" -> 12 | ||
) | ||
|
||
val pricesAnnualNew: Map[String, Double] = Map( | ||
"GBP" -> 120, | ||
"USD" -> 150, | ||
"CAD" -> 150, | ||
"AUD" -> 200, | ||
"NZD" -> 200, | ||
"EUR" -> 120 | ||
) | ||
|
||
// ------------------------------------------------ | ||
// Data Functions | ||
// ------------------------------------------------ | ||
|
||
// ------------------------------------------------ | ||
// Prices | ||
|
||
def getOldPrice(billingPeriod: BillingPeriod, currency: String): Option[Double] = { | ||
billingPeriod match { | ||
case Monthly => pricesMonthlyOld.get(currency) | ||
case Annual => pricesAnnualOld.get(currency) | ||
case _ => None | ||
} | ||
} | ||
|
||
def getNewPrice(billingPeriod: BillingPeriod, currency: String): Option[Double] = { | ||
billingPeriod match { | ||
case Monthly => pricesMonthlyNew.get(currency) | ||
case Annual => pricesAnnualNew.get(currency) | ||
case _ => None | ||
} | ||
} | ||
|
||
// ------------------------------------------------ | ||
// Cancellation Saves | ||
|
||
def cancellationSaveRatePlan(subscription: ZuoraSubscription): Option[ZuoraRatePlan] = { | ||
subscription.ratePlans.find(rp => rp.ratePlanName.contains("Cancellation Save Discount")) | ||
} | ||
|
||
def isInCancellationSave(subscription: ZuoraSubscription): Boolean = { | ||
cancellationSaveRatePlan(subscription: ZuoraSubscription).isDefined | ||
} | ||
|
||
def cancellationSaveEffectiveDate(subscription: ZuoraSubscription): Option[LocalDate] = { | ||
for { | ||
ratePlan <- cancellationSaveRatePlan(subscription) | ||
charge <- ratePlan.ratePlanCharges.headOption | ||
date <- charge.effectiveStartDate | ||
} yield date | ||
} | ||
|
||
def isUnderActiveCancellationSave(subscription: ZuoraSubscription, today: LocalDate): Boolean = { | ||
cancellationSaveEffectiveDate(subscription: ZuoraSubscription) match { | ||
case None => false | ||
case Some(date) => (date == today) || today.isBefore(date) | ||
} | ||
} | ||
|
||
// ------------------------------------------------ | ||
// Subscription Data | ||
|
||
def supporterPlusV2RatePlan(subscription: ZuoraSubscription): Either[AmendmentDataFailure, ZuoraRatePlan] = { | ||
subscription.ratePlans.find(rp => | ||
rp.ratePlanName.contains("Supporter Plus V2") && !rp.lastChangeType.contains("Remove") | ||
) match { | ||
case None => | ||
Left( | ||
AmendmentDataFailure( | ||
s"Subscription ${subscription.subscriptionNumber} doesn't have any `Add`ed rate plan with pattern `Supporter Plus V2`" | ||
) | ||
) | ||
case Some(ratePlan) => Right(ratePlan) | ||
} | ||
} | ||
|
||
def supporterPlusBaseRatePlanCharge( | ||
subscriptionNumber: String, | ||
ratePlan: ZuoraRatePlan | ||
): Either[AmendmentDataFailure, ZuoraRatePlanCharge] = { | ||
ratePlan.ratePlanCharges.find(rpc => rpc.name.contains("Supporter Plus")) match { | ||
case None => { | ||
Left( | ||
AmendmentDataFailure(s"Subscription ${subscriptionNumber} has a rate plan (${ratePlan}), but with no charge") | ||
) | ||
} | ||
case Some(ratePlanCharge) => Right(ratePlanCharge) | ||
} | ||
} | ||
|
||
def supporterPlusContributionRatePlanCharge( | ||
subscriptionNumber: String, | ||
ratePlan: ZuoraRatePlan | ||
): Either[AmendmentDataFailure, ZuoraRatePlanCharge] = { | ||
ratePlan.ratePlanCharges.find(rpc => rpc.name.contains("Contribution")) match { | ||
case None => { | ||
Left( | ||
AmendmentDataFailure(s"Subscription ${subscriptionNumber} has a rate plan (${ratePlan}), but with no charge") | ||
) | ||
} | ||
case Some(ratePlanCharge) => Right(ratePlanCharge) | ||
} | ||
} | ||
|
||
// ------------------------------------------------ | ||
// Notification helpers | ||
|
||
/* | ||
Date: September 2024 | ||
Author: Pascal | ||
Comment Group: 602514a6-5e53 | ||
These functions have been added to implement the extra fields added to EmailPayloadSubscriberAttributes | ||
as part of the set up of the SupporterPlus 2024 migration (see Comment Group: 602514a6-5e53) | ||
*/ | ||
|
||
def previousBaseAmount(subscription: ZuoraSubscription): Either[Failure, Option[BigDecimal]] = { | ||
for { | ||
ratePlan <- supporterPlusV2RatePlan(subscription) | ||
ratePlanCharge <- supporterPlusBaseRatePlanCharge(subscription.subscriptionNumber, ratePlan) | ||
} yield ratePlanCharge.price | ||
} | ||
|
||
def newBaseAmount(subscription: ZuoraSubscription): Either[Failure, Option[BigDecimal]] = { | ||
for { | ||
ratePlan <- supporterPlusV2RatePlan(subscription) | ||
billingPeriod <- ZuoraRatePlan.ratePlanToBillingPeriod(ratePlan).toRight(AmendmentDataFailure("")) | ||
ratePlanCharge <- supporterPlusBaseRatePlanCharge(subscription.subscriptionNumber, ratePlan) | ||
currency = ratePlanCharge.currency | ||
oldBaseAmountOpt <- previousBaseAmount(subscription) | ||
oldBaseAmount <- oldBaseAmountOpt.toRight( | ||
AmendmentDataFailure( | ||
s"(error: 164d8f1c-6dc6) could not extract base amount for subscription ${subscription.subscriptionNumber}" | ||
) | ||
) | ||
newPriceFromPriceGrid <- getNewPrice(billingPeriod, currency) | ||
.map(BigDecimal(_)) | ||
.toRight( | ||
AmendmentDataFailure( | ||
s"(error: 611aedea-0478) could not getNewPrice for (billingPeriod, currency) and (${billingPeriod}, ${currency})" | ||
) | ||
) | ||
} yield { | ||
Some((oldBaseAmount * BigDecimal(1.27)).min(newPriceFromPriceGrid)) | ||
} | ||
} | ||
|
||
def contributionAmount(subscription: ZuoraSubscription): Either[Failure, Option[BigDecimal]] = { | ||
for { | ||
ratePlan <- supporterPlusV2RatePlan(subscription) | ||
ratePlanCharge <- supporterPlusContributionRatePlanCharge(subscription.subscriptionNumber, ratePlan) | ||
} yield ratePlanCharge.price | ||
} | ||
|
||
def previousCombinedAmount(subscription: ZuoraSubscription): Either[Failure, Option[BigDecimal]] = { | ||
for { | ||
contributionAmountOpt <- contributionAmount(subscription) | ||
previousBaseAmountOpt <- previousBaseAmount(subscription) | ||
} yield ( | ||
for { | ||
contributionAmount <- contributionAmountOpt | ||
previousBaseAmount <- previousBaseAmountOpt | ||
} yield contributionAmount + previousBaseAmount | ||
) | ||
} | ||
|
||
def newCombinedAmount(subscription: ZuoraSubscription): Either[Failure, Option[BigDecimal]] = { | ||
for { | ||
contributionAmountOpt <- contributionAmount(subscription) | ||
previousBaseAmountOpt <- newBaseAmount(subscription) | ||
} yield ( | ||
for { | ||
contributionAmount <- contributionAmountOpt | ||
previousBaseAmount <- previousBaseAmountOpt | ||
} yield contributionAmount + previousBaseAmount | ||
) | ||
} | ||
|
||
def hasNonTrivialContribution(subscription: ZuoraSubscription): Either[Failure, Boolean] = { | ||
for { | ||
amountOpt <- contributionAmount(subscription: ZuoraSubscription) | ||
amount <- amountOpt.toRight( | ||
AmendmentDataFailure( | ||
s"(error: 232760f5) could not extract contribution amount for subscription ${subscription.subscriptionNumber}" | ||
) | ||
) | ||
} yield amount > 0 | ||
} | ||
|
||
// ------------------------------------------------------------------- | ||
// Braze names | ||
|
||
def brazeName(subscription: ZuoraSubscription): Either[Failure, String] = { | ||
for { | ||
status <- hasNonTrivialContribution(subscription: ZuoraSubscription) | ||
} yield { | ||
if (status) { | ||
"SV_SP2_Contributors_PriceRise2024" | ||
} else { | ||
"SV_SP2_PriceRise2024" | ||
} | ||
} | ||
} | ||
|
||
// ------------------------------------------------ | ||
// Primary Interface | ||
// ------------------------------------------------ | ||
|
||
def priceData( | ||
subscription: ZuoraSubscription | ||
): Either[AmendmentDataFailure, PriceData] = { | ||
for { | ||
ratePlan <- supporterPlusV2RatePlan(subscription) | ||
billingPeriod <- ZuoraRatePlan.ratePlanToBillingPeriod(ratePlan).toRight(AmendmentDataFailure("")) | ||
ratePlanCharge <- supporterPlusBaseRatePlanCharge(subscription.subscriptionNumber, ratePlan) | ||
currency = ratePlanCharge.currency | ||
oldPrice <- ratePlanCharge.price.toRight(AmendmentDataFailure("")) | ||
newPrice <- getNewPrice(billingPeriod, currency).toRight(AmendmentDataFailure("")) | ||
} yield PriceData(currency, oldPrice, newPrice, BillingPeriod.toString(billingPeriod)) | ||
} | ||
|
||
def zuoraUpdate( | ||
subscription: ZuoraSubscription, | ||
effectiveDate: LocalDate, | ||
): Either[AmendmentDataFailure, ZuoraSubscriptionUpdate] = { | ||
for { | ||
existingRatePlan <- supporterPlusV2RatePlan(subscription) | ||
existingBaseRatePlanCharge <- supporterPlusBaseRatePlanCharge( | ||
subscription.subscriptionNumber, | ||
existingRatePlan | ||
) | ||
billingPeriod <- ZuoraRatePlan | ||
.ratePlanToBillingPeriod(existingRatePlan) | ||
.toRight( | ||
AmendmentDataFailure( | ||
s"[17469705] Could not determine the billing period for subscription ${subscription.subscriptionNumber}" | ||
) | ||
) | ||
} yield { | ||
ZuoraSubscriptionUpdate( | ||
add = List( | ||
AddZuoraRatePlan( | ||
productRatePlanId = existingRatePlan.productRatePlanId, | ||
contractEffectiveDate = effectiveDate, | ||
chargeOverrides = List( | ||
ChargeOverride( | ||
productRatePlanChargeId = existingBaseRatePlanCharge.productRatePlanChargeId, | ||
billingPeriod = BillingPeriod.toString(billingPeriod), | ||
price = 120.0 | ||
) | ||
) | ||
) | ||
), | ||
remove = List( | ||
RemoveZuoraRatePlan( | ||
ratePlanId = existingRatePlan.id, | ||
contractEffectiveDate = effectiveDate | ||
) | ||
), | ||
currentTerm = None, | ||
currentTermPeriodType = None | ||
) | ||
} | ||
} | ||
} |
15 changes: 15 additions & 0 deletions
15
lambda/src/main/scala/pricemigrationengine/model/Estimation1.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
package pricemigrationengine.model | ||
|
||
import java.time.LocalDate | ||
|
||
object Estimation1 { | ||
def isProcessable(item: CohortItem, today: LocalDate): Boolean = { | ||
// This function looks at the `doNotProcessUntil` attribute and returns whether the item | ||
// should go through the Estimation step. See comment group: 6157ec78 | ||
// Note that LocalDate.isAfter is strict (see EstimationTest for details) | ||
item.doNotProcessUntil match { | ||
case None => true | ||
case Some(date) => (today == date) || today.isAfter(date) | ||
} | ||
} | ||
} |
46 changes: 46 additions & 0 deletions
46
lambda/src/test/resources/Migrations/SupporterPlus2024/annual/account.json
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,46 @@ | ||
{ | ||
"basicInfo" : { | ||
"id" : "ACCOUNT-ID", | ||
"name" : "ACCOUNT-NAME", | ||
"accountNumber" : "ACCOUNT-NUMBER", | ||
"notes" : null, | ||
"status" : "Active", | ||
"crmId" : "ACCOUNT-NAME", | ||
"batch" : "Batch1", | ||
"invoiceTemplateId" : "2c92a0fd5ecce80c015ee71028643020", | ||
"communicationProfileId" : null, | ||
"profileNumber" : null, | ||
"purchaseOrderNumber" : null, | ||
"customerServiceRepName" : null, | ||
"ScrubbedOn__c" : null, | ||
"IdentityId__c" : "IDENTITY-ID-C", | ||
"sfContactId__c" : "SF-CONTACT-ID-C", | ||
"CCURN__c" : null, | ||
"SpecialDeliveryInstructions__c" : null, | ||
"NonStandardDataReason__c" : null, | ||
"Scrubbed__c" : null, | ||
"ProcessingAdvice__c" : "NoAdvice", | ||
"CreatedRequestId__c" : "cd0ca593-02bf-4f9a-0000-00000000bff6", | ||
"RetryStatus__c" : null, | ||
"salesRep" : null, | ||
"creditMemoTemplateId" : null, | ||
"debitMemoTemplateId" : null, | ||
"summaryStatementTemplateId" : null, | ||
"sequenceSetId" : null | ||
}, | ||
"billingAndPayment" : null, | ||
"metrics" : { | ||
"balance" : 0.000000000, | ||
"currency" : "AUD", | ||
"totalInvoiceBalance" : 0.000000000, | ||
"creditBalance" : 0.000000000, | ||
"totalDebitMemoBalance" : 0.000000000, | ||
"unappliedPaymentAmount" : 0.000000000, | ||
"unappliedCreditMemoAmount" : 0.000000000, | ||
"contractedMrr" : 41.670000000 | ||
}, | ||
"billToContact" : null, | ||
"soldToContact" : null, | ||
"taxInfo" : null, | ||
"success" : true | ||
} |
Oops, something went wrong.