diff --git a/core/src/main/java/google/registry/flows/custom/DomainPricingCustomLogic.java b/core/src/main/java/google/registry/flows/custom/DomainPricingCustomLogic.java index f77afe1b8..ef8e786af 100644 --- a/core/src/main/java/google/registry/flows/custom/DomainPricingCustomLogic.java +++ b/core/src/main/java/google/registry/flows/custom/DomainPricingCustomLogic.java @@ -33,7 +33,7 @@ import org.joda.time.DateTime; */ public class DomainPricingCustomLogic extends BaseFlowCustomLogic { - protected DomainPricingCustomLogic( + public DomainPricingCustomLogic( EppInput eppInput, SessionMetadata sessionMetadata, FlowMetadata flowMetadata) { super(eppInput, sessionMetadata, flowMetadata); } diff --git a/core/src/main/java/google/registry/flows/domain/DomainFlowUtils.java b/core/src/main/java/google/registry/flows/domain/DomainFlowUtils.java index 3ea488afb..1e298adfd 100644 --- a/core/src/main/java/google/registry/flows/domain/DomainFlowUtils.java +++ b/core/src/main/java/google/registry/flows/domain/DomainFlowUtils.java @@ -680,7 +680,7 @@ public class DomainFlowUtils { break; case RENEW: builder.setAvailIfSupported(true); - fees = pricingLogic.getRenewPrice(registry, domainNameString, now, years).getFees(); + fees = pricingLogic.getRenewPrice(registry, domainNameString, now, years, null).getFees(); break; case RESTORE: // The minimum allowable period per the EPP spec is 1, so, strangely, 1 year still has to be diff --git a/core/src/main/java/google/registry/flows/domain/DomainPricingLogic.java b/core/src/main/java/google/registry/flows/domain/DomainPricingLogic.java index afacf5b62..3867c2a5e 100644 --- a/core/src/main/java/google/registry/flows/domain/DomainPricingLogic.java +++ b/core/src/main/java/google/registry/flows/domain/DomainPricingLogic.java @@ -14,8 +14,11 @@ package google.registry.flows.domain; +import static com.google.common.base.Preconditions.checkArgument; import static google.registry.flows.domain.DomainFlowUtils.zeroInCurrency; import static google.registry.pricing.PricingEngineProxy.getPricesForDomainName; +import static google.registry.util.DomainNameUtils.getTldFromDomainName; +import static google.registry.util.PreconditionsUtils.checkArgumentPresent; import com.google.common.net.InternetDomainName; import google.registry.flows.EppException; @@ -27,15 +30,16 @@ import google.registry.flows.custom.DomainPricingCustomLogic.RenewPriceParameter import google.registry.flows.custom.DomainPricingCustomLogic.RestorePriceParameters; import google.registry.flows.custom.DomainPricingCustomLogic.TransferPriceParameters; import google.registry.flows.custom.DomainPricingCustomLogic.UpdatePriceParameters; +import google.registry.model.billing.BillingEvent.Recurring; import google.registry.model.domain.fee.BaseFee; import google.registry.model.domain.fee.BaseFee.FeeType; import google.registry.model.domain.fee.Fee; import google.registry.model.domain.token.AllocationToken; import google.registry.model.pricing.PremiumPricingEngine.DomainPrices; import google.registry.model.tld.Registry; -import java.math.BigDecimal; import java.math.RoundingMode; import java.util.Optional; +import javax.annotation.Nullable; import javax.inject.Inject; import org.joda.money.CurrencyUnit; import org.joda.money.Money; @@ -102,18 +106,63 @@ public final class DomainPricingLogic { .build()); } - /** Returns a new renew price for the pricer. */ - @SuppressWarnings("unused") - FeesAndCredits getRenewPrice(Registry registry, String domainName, DateTime dateTime, int years) + /** Returns a new renewal cost for the pricer. */ + FeesAndCredits getRenewPrice( + Registry registry, + String domainName, + DateTime dateTime, + int years, + @Nullable Recurring recurringBillingEvent) throws EppException { - DomainPrices domainPrices = getPricesForDomainName(domainName, dateTime); - BigDecimal renewCost = domainPrices.getRenewCost().multipliedBy(years).getAmount(); + checkArgument(years > 0, "Number of years must be positive"); + Money renewCost; + boolean isRenewCostPremiumPrice; + // recurring billing event is null if the domain is still available. Billing events are created + // in the process of domain creation. + if (recurringBillingEvent == null) { + DomainPrices domainPrices = getPricesForDomainName(domainName, dateTime); + renewCost = domainPrices.getRenewCost().multipliedBy(years); + isRenewCostPremiumPrice = domainPrices.isPremium(); + } else { + switch (recurringBillingEvent.getRenewalPriceBehavior()) { + case DEFAULT: + DomainPrices domainPrices = getPricesForDomainName(domainName, dateTime); + renewCost = domainPrices.getRenewCost().multipliedBy(years); + isRenewCostPremiumPrice = domainPrices.isPremium(); + break; + // if the renewal price behavior is specified, then the renewal price should be the same + // as the creation price, which is stored in the billing event as the renewal price + case SPECIFIED: + checkArgumentPresent( + recurringBillingEvent.getRenewalPrice(), + "Unexpected behavior: renewal price cannot be null when renewal behavior is" + + " SPECIFIED"); + renewCost = recurringBillingEvent.getRenewalPrice().get().multipliedBy(years); + isRenewCostPremiumPrice = false; + break; + // if the renewal price behavior is nonpremium, it means that the domain should be renewed + // at standard price of domains at the time, even if the domain is premium + case NONPREMIUM: + renewCost = + Registry.get(getTldFromDomainName(domainName)) + .getStandardRenewCost(dateTime) + .multipliedBy(years); + isRenewCostPremiumPrice = false; + break; + default: + throw new IllegalArgumentException( + String.format( + "Unknown RenewalPriceBehavior enum value: %s", + recurringBillingEvent.getRenewalPriceBehavior())); + } + } return customLogic.customizeRenewPrice( RenewPriceParameters.newBuilder() .setFeesAndCredits( new FeesAndCredits.Builder() - .setCurrency(registry.getCurrency()) - .addFeeOrCredit(Fee.create(renewCost, FeeType.RENEW, domainPrices.isPremium())) + .setCurrency(renewCost.getCurrencyUnit()) + .addFeeOrCredit( + Fee.create(renewCost.getAmount(), FeeType.RENEW, isRenewCostPremiumPrice)) .build()) .setRegistry(registry) .setDomainName(InternetDomainName.from(domainName)) diff --git a/core/src/main/java/google/registry/flows/domain/DomainRenewFlow.java b/core/src/main/java/google/registry/flows/domain/DomainRenewFlow.java index 2d0e6bfd7..09fa4e8fc 100644 --- a/core/src/main/java/google/registry/flows/domain/DomainRenewFlow.java +++ b/core/src/main/java/google/registry/flows/domain/DomainRenewFlow.java @@ -155,7 +155,8 @@ public final class DomainRenewFlow implements TransactionalFlow { Optional feeRenew = eppInput.getSingleExtension(FeeRenewCommandExtension.class); FeesAndCredits feesAndCredits = - pricingLogic.getRenewPrice(Registry.get(existingDomain.getTld()), targetId, now, years); + pricingLogic.getRenewPrice( + Registry.get(existingDomain.getTld()), targetId, now, years, null); validateFeeChallenge(targetId, now, feeRenew, feesAndCredits); flowCustomLogic.afterValidation( AfterValidationParameters.newBuilder() diff --git a/core/src/test/java/google/registry/flows/domain/DomainFlowUtilsTest.java b/core/src/test/java/google/registry/flows/domain/DomainFlowUtilsTest.java index 401112ea7..506203880 100644 --- a/core/src/test/java/google/registry/flows/domain/DomainFlowUtilsTest.java +++ b/core/src/test/java/google/registry/flows/domain/DomainFlowUtilsTest.java @@ -45,6 +45,7 @@ import google.registry.testing.TestOfyAndSql; import org.joda.money.Money; import org.junit.jupiter.api.BeforeEach; +/** Unit tests for {@link DomainFlowUtils}. */ @DualDatabaseTest class DomainFlowUtilsTest extends ResourceFlowTestCase { diff --git a/core/src/test/java/google/registry/flows/domain/DomainPricingLogicTest.java b/core/src/test/java/google/registry/flows/domain/DomainPricingLogicTest.java new file mode 100644 index 000000000..1c9bfe275 --- /dev/null +++ b/core/src/test/java/google/registry/flows/domain/DomainPricingLogicTest.java @@ -0,0 +1,405 @@ +// Copyright 2022 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.flows.domain; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth8.assertThat; +import static google.registry.model.billing.BillingEvent.Flag.AUTO_RENEW; +import static google.registry.model.billing.BillingEvent.RenewalPriceBehavior.DEFAULT; +import static google.registry.model.billing.BillingEvent.RenewalPriceBehavior.NONPREMIUM; +import static google.registry.model.billing.BillingEvent.RenewalPriceBehavior.SPECIFIED; +import static google.registry.model.domain.fee.BaseFee.FeeType.RENEW; +import static google.registry.model.reporting.HistoryEntry.Type.DOMAIN_CREATE; +import static google.registry.testing.DatabaseHelper.createTld; +import static google.registry.testing.DatabaseHelper.newDomainBase; +import static google.registry.testing.DatabaseHelper.persistPremiumList; +import static google.registry.testing.DatabaseHelper.persistResource; +import static google.registry.util.DateTimeUtils.END_OF_TIME; +import static google.registry.util.DateTimeUtils.START_OF_TIME; +import static org.joda.money.CurrencyUnit.USD; +import static org.joda.time.DateTimeZone.UTC; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.ImmutableSortedMap; +import google.registry.flows.EppException; +import google.registry.flows.FlowMetadata; +import google.registry.flows.HttpSessionMetadata; +import google.registry.flows.SessionMetadata; +import google.registry.flows.custom.DomainPricingCustomLogic; +import google.registry.model.billing.BillingEvent; +import google.registry.model.billing.BillingEvent.Reason; +import google.registry.model.billing.BillingEvent.Recurring; +import google.registry.model.billing.BillingEvent.RenewalPriceBehavior; +import google.registry.model.domain.DomainBase; +import google.registry.model.domain.DomainHistory; +import google.registry.model.domain.fee.Fee; +import google.registry.model.eppinput.EppInput; +import google.registry.model.tld.Registry; +import google.registry.testing.AppEngineExtension; +import google.registry.testing.DualDatabaseTest; +import google.registry.testing.FakeClock; +import google.registry.testing.FakeHttpSession; +import google.registry.testing.TestOfyAndSql; +import google.registry.util.Clock; +import java.util.Optional; +import javax.inject.Inject; +import org.joda.money.Money; +import org.joda.time.DateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.mockito.Mock; + +/** Unit tests for {@link DomainPricingLogic}. */ +@DualDatabaseTest +public class DomainPricingLogicTest { + DomainPricingLogic domainPricingLogic = new DomainPricingLogic(); + + @RegisterExtension + public final AppEngineExtension appEngine = + AppEngineExtension.builder().withDatastoreAndCloudSql().build(); + + @Inject Clock clock = new FakeClock(DateTime.now(UTC)); + @Mock EppInput eppInput; + SessionMetadata sessionMetadata; + @Mock FlowMetadata flowMetadata; + Registry registry; + DomainBase domain; + + @BeforeEach + void beforeEach() throws Exception { + createTld("example"); + sessionMetadata = new HttpSessionMetadata(new FakeHttpSession()); + domainPricingLogic.customLogic = + new DomainPricingCustomLogic(eppInput, sessionMetadata, flowMetadata); + registry = + persistResource( + Registry.get("example") + .asBuilder() + .setRenewBillingCostTransitions( + ImmutableSortedMap.of( + START_OF_TIME, Money.of(USD, 1), clock.nowUtc(), Money.of(USD, 10))) + .setPremiumList(persistPremiumList("tld2", USD, "premium,USD 100")) + .build()); + } + + /** helps to set up the domain info and returns a recurring billing event for testing */ + private Recurring persistDomainAndSetRecurringBillingEvent( + String domainName, RenewalPriceBehavior renewalPriceBehavior, Optional renewalPrice) { + domain = + persistResource( + newDomainBase(domainName) + .asBuilder() + .setCreationTimeForTest(DateTime.parse("1999-01-05T00:00:00Z")) + .build()); + DomainHistory historyEntry = + persistResource( + new DomainHistory.Builder() + .setRegistrarId(domain.getCreationRegistrarId()) + .setType(DOMAIN_CREATE) + .setModificationTime(DateTime.parse("1999-01-05T00:00:00Z")) + .setDomain(domain) + .build()); + Recurring recurring = + persistResource( + new BillingEvent.Recurring.Builder() + .setParent(historyEntry) + .setRegistrarId(domain.getCreationRegistrarId()) + .setEventTime((DateTime.parse("1999-01-05T00:00:00Z"))) + .setFlags(ImmutableSet.of(AUTO_RENEW)) + .setId(2L) + .setReason(Reason.RENEW) + .setRenewalPriceBehavior(renewalPriceBehavior) + .setRenewalPrice(renewalPrice.isPresent() ? renewalPrice.get() : null) + .setRecurrenceEndTime(END_OF_TIME) + .setTargetId(domain.getDomainName()) + .build()); + persistResource(domain.asBuilder().setAutorenewBillingEvent(recurring.createVKey()).build()); + return recurring; + } + + @TestOfyAndSql + void testGetDomainRenewPrice_oneYear_standardDomain_noBilling_isStandardPrice() + throws EppException { + assertThat( + domainPricingLogic.getRenewPrice(registry, "standard.example", clock.nowUtc(), 1, null)) + .isEqualTo( + new FeesAndCredits.Builder() + .setCurrency(USD) + .addFeeOrCredit(Fee.create(Money.of(USD, 10).getAmount(), RENEW, false)) + .build()); + } + + @TestOfyAndSql + void testGetDomainRenewPrice_multiYear_standardDomain_noBilling_isStandardPrice() + throws EppException { + assertThat( + domainPricingLogic.getRenewPrice(registry, "standard.example", clock.nowUtc(), 5, null)) + .isEqualTo( + new FeesAndCredits.Builder() + .setCurrency(USD) + .addFeeOrCredit(Fee.create(Money.of(USD, 50).getAmount(), RENEW, false)) + .build()); + } + + @TestOfyAndSql + void testGetDomainRenewPrice_oneYear_premiumDomain_noBilling_isPremiumPrice() + throws EppException { + assertThat( + domainPricingLogic.getRenewPrice(registry, "premium.example", clock.nowUtc(), 1, null)) + .isEqualTo( + new FeesAndCredits.Builder() + .setCurrency(USD) + .addFeeOrCredit(Fee.create(Money.of(USD, 100).getAmount(), RENEW, true)) + .build()); + } + + @TestOfyAndSql + void testGetDomainRenewPrice_multiYear_premiumDomain_noBilling_isPremiumPrice() + throws EppException { + assertThat( + domainPricingLogic.getRenewPrice(registry, "premium.example", clock.nowUtc(), 5, null)) + .isEqualTo( + new FeesAndCredits.Builder() + .setCurrency(USD) + .addFeeOrCredit(Fee.create(Money.of(USD, 500).getAmount(), RENEW, true)) + .build()); + } + + @TestOfyAndSql + void testGetDomainRenewPrice_oneYear_premiumDomain_default_isPremiumPrice() throws EppException { + assertThat( + domainPricingLogic.getRenewPrice( + registry, + "premium.example", + clock.nowUtc(), + 1, + persistDomainAndSetRecurringBillingEvent( + "premium.example", DEFAULT, Optional.empty()))) + .isEqualTo( + new FeesAndCredits.Builder() + .setCurrency(USD) + .addFeeOrCredit(Fee.create(Money.of(USD, 100).getAmount(), RENEW, true)) + .build()); + } + + @TestOfyAndSql + void testGetDomainRenewPrice_multiYear_premiumDomain_default_isPremiumCost() throws EppException { + assertThat( + domainPricingLogic.getRenewPrice( + registry, + "premium.example", + clock.nowUtc(), + 5, + persistDomainAndSetRecurringBillingEvent( + "premium.example", DEFAULT, Optional.empty()))) + .isEqualTo( + new FeesAndCredits.Builder() + .setCurrency(USD) + .addFeeOrCredit(Fee.create(Money.of(USD, 500).getAmount(), RENEW, true)) + .build()); + } + + @TestOfyAndSql + void testGetDomainRenewPrice_oneYear_standardDomain_default_isNonPremiumPrice() + throws EppException { + assertThat( + domainPricingLogic.getRenewPrice( + registry, + "standard.example", + clock.nowUtc(), + 1, + persistDomainAndSetRecurringBillingEvent( + "premium.example", DEFAULT, Optional.empty()))) + .isEqualTo( + new FeesAndCredits.Builder() + .setCurrency(USD) + .addFeeOrCredit(Fee.create(Money.of(USD, 10).getAmount(), RENEW, false)) + .build()); + } + + @TestOfyAndSql + void testGetDomainRenewPrice_multiYear_standardDomain_default_isNonPremiumCost() + throws EppException { + assertThat( + domainPricingLogic.getRenewPrice( + registry, + "standard.example", + clock.nowUtc(), + 5, + persistDomainAndSetRecurringBillingEvent( + "standard.example", DEFAULT, Optional.empty()))) + .isEqualTo( + new FeesAndCredits.Builder() + .setCurrency(USD) + .addFeeOrCredit(Fee.create(Money.of(USD, 50).getAmount(), RENEW, false)) + .build()); + } + + @TestOfyAndSql + void testGetDomainRenewPrice_oneYear_premiumDomain_anchorTenant_isNonPremiumPrice() + throws EppException { + assertThat( + domainPricingLogic.getRenewPrice( + registry, + "premium.example", + clock.nowUtc(), + 1, + persistDomainAndSetRecurringBillingEvent( + "premium.example", NONPREMIUM, Optional.empty()))) + .isEqualTo( + new FeesAndCredits.Builder() + .setCurrency(USD) + .addFeeOrCredit(Fee.create(Money.of(USD, 10).getAmount(), RENEW, false)) + .build()); + } + + @TestOfyAndSql + void testGetDomainRenewPrice_multiYear_premiumDomain_anchorTenant_isNonPremiumCost() + throws EppException { + assertThat( + domainPricingLogic.getRenewPrice( + registry, + "premium.example", + clock.nowUtc(), + 5, + persistDomainAndSetRecurringBillingEvent( + "premium.example", NONPREMIUM, Optional.empty()))) + .isEqualTo( + new FeesAndCredits.Builder() + .setCurrency(USD) + .addFeeOrCredit(Fee.create(Money.of(USD, 50).getAmount(), RENEW, false)) + .build()); + } + + @TestOfyAndSql + void testGetDomainRenewPrice_oneYear_standardDomain_anchorTenant_isNonPremiumPrice() + throws EppException { + assertThat( + domainPricingLogic.getRenewPrice( + registry, + "standard.example", + clock.nowUtc(), + 1, + persistDomainAndSetRecurringBillingEvent( + "standard.example", NONPREMIUM, Optional.empty()))) + .isEqualTo( + new FeesAndCredits.Builder() + .setCurrency(USD) + .addFeeOrCredit(Fee.create(Money.of(USD, 10).getAmount(), RENEW, false)) + .build()); + } + + @TestOfyAndSql + void testGetDomainRenewPrice_multiYear_standardDomain_anchorTenant_isNonPremiumCost() + throws EppException { + assertThat( + domainPricingLogic.getRenewPrice( + registry, + "standard.example", + clock.nowUtc(), + 5, + persistDomainAndSetRecurringBillingEvent( + "standard.example", NONPREMIUM, Optional.empty()))) + .isEqualTo( + new FeesAndCredits.Builder() + .setCurrency(USD) + .addFeeOrCredit(Fee.create(Money.of(USD, 50).getAmount(), RENEW, false)) + .build()); + } + + @TestOfyAndSql + void testGetDomainRenewPrice_oneYear_standardDomain_internalRegistration_isSpecifiedPrice() + throws EppException { + assertThat( + domainPricingLogic.getRenewPrice( + registry, + "standard.example", + clock.nowUtc(), + 1, + persistDomainAndSetRecurringBillingEvent( + "standard.example", SPECIFIED, Optional.of(Money.of(USD, 1))))) + .isEqualTo( + new FeesAndCredits.Builder() + .setCurrency(USD) + .addFeeOrCredit(Fee.create(Money.of(USD, 1).getAmount(), RENEW, false)) + .build()); + } + + @TestOfyAndSql + void testGetDomainRenewPrice_multiYear_standardDomain_internalRegistration_isSpecifiedPrice() + throws EppException { + assertThat( + domainPricingLogic.getRenewPrice( + registry, + "standard.example", + clock.nowUtc(), + 5, + persistDomainAndSetRecurringBillingEvent( + "standard.example", SPECIFIED, Optional.of(Money.of(USD, 1))))) + .isEqualTo( + new FeesAndCredits.Builder() + .setCurrency(USD) + .addFeeOrCredit(Fee.create(Money.of(USD, 5).getAmount(), RENEW, false)) + .build()); + } + + @TestOfyAndSql + void testGetDomainRenewPrice_oneYear_premiumDomain_internalRegistration_isSpecifiedPrice() + throws EppException { + assertThat( + domainPricingLogic.getRenewPrice( + registry, + "premium.example", + clock.nowUtc(), + 1, + persistDomainAndSetRecurringBillingEvent( + "premium.example", SPECIFIED, Optional.of(Money.of(USD, 17))))) + .isEqualTo( + new FeesAndCredits.Builder() + .setCurrency(USD) + .addFeeOrCredit(Fee.create(Money.of(USD, 17).getAmount(), RENEW, false)) + .build()); + } + + @TestOfyAndSql + void testGetDomainRenewPrice_multiYear_premiumDomain_internalRegistration_isSpecifiedPrice() + throws EppException { + assertThat( + domainPricingLogic.getRenewPrice( + registry, + "premium.example", + clock.nowUtc(), + 5, + persistDomainAndSetRecurringBillingEvent( + "premium.example", SPECIFIED, Optional.of(Money.of(USD, 17))))) + .isEqualTo( + new FeesAndCredits.Builder() + .setCurrency(USD) + .addFeeOrCredit(Fee.create(Money.of(USD, 85).getAmount(), RENEW, false)) + .build()); + } + + @TestOfyAndSql + void testGetDomainRenewPrice_negativeYear_throwsException() throws EppException { + IllegalArgumentException thrown = + assertThrows( + IllegalArgumentException.class, + () -> + domainPricingLogic.getRenewPrice( + registry, "standard.example", clock.nowUtc(), -1, null)); + assertThat(thrown).hasMessageThat().isEqualTo("Number of years must be positive"); + } +}