From b2a78b5d68fbf659d58fa40c8870c8610d66868d Mon Sep 17 00:00:00 2001 From: Ben McIlwain Date: Wed, 5 Aug 2020 17:54:47 -0400 Subject: [PATCH] Allow allocation token discounts on premiums and for multiple years (#744) * Allow allocation token discounts on premiums and for multiple years * Add domain check flow tests * Address code review comments * Update schema file --- .../flows/domain/DomainPricingLogic.java | 24 +- .../registry/model/BackupGroupRoot.java | 3 +- .../registry/model/domain/DomainContent.java | 6 +- .../registry/model/domain/DomainHistory.java | 8 +- .../model/domain/token/AllocationToken.java | 56 ++++- .../GenerateAllocationTokensCommand.java | 21 +- .../tools/GetAllocationTokenCommand.java | 9 +- .../tools/UpdateAllocationTokensCommand.java | 15 ++ .../flows/domain/DomainCheckFlowTest.java | 66 +++++- .../flows/domain/DomainCreateFlowTest.java | 216 +++++++++++++++--- .../domain/token/AllocationTokenTest.java | 82 ++++++- .../model/history/ContactHistoryTest.java | 6 +- .../model/history/DomainHistoryTest.java | 3 +- .../registry/testing/DatastoreHelper.java | 8 + .../GenerateAllocationTokensCommandTest.java | 27 +-- .../UpdateAllocationTokensCommandTest.java | 18 ++ ...domain_check_allocationtoken_promotion.xml | 39 ++++ ...eck_allocationtoken_promotion_response.xml | 47 ++++ .../domain/domain_create_allocationtoken.xml | 2 +- .../domain_create_premium_allocationtoken.xml | 4 +- .../domain/domain_create_response_premium.xml | 4 +- .../domain_create_response_wildcard.xml | 19 ++ .../google/registry/model/schema.txt | 2 + .../google/registry/util/CollectionUtils.java | 17 +- .../registry/util/PreconditionsUtils.java | 15 +- 25 files changed, 606 insertions(+), 111 deletions(-) create mode 100644 core/src/test/resources/google/registry/flows/domain/domain_check_allocationtoken_promotion.xml create mode 100644 core/src/test/resources/google/registry/flows/domain/domain_check_allocationtoken_promotion_response.xml create mode 100644 core/src/test/resources/google/registry/flows/domain/domain_create_response_wildcard.xml 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 ffd7129df..15f8e9fde 100644 --- a/core/src/main/java/google/registry/flows/domain/DomainPricingLogic.java +++ b/core/src/main/java/google/registry/flows/domain/DomainPricingLogic.java @@ -60,7 +60,7 @@ public final class DomainPricingLogic { *

If {@code allocationToken} is present and the domain is non-premium, that discount will be * applied to the first year. */ - public FeesAndCredits getCreatePrice( + FeesAndCredits getCreatePrice( Registry registry, String domainName, DateTime dateTime, @@ -104,8 +104,8 @@ public final class DomainPricingLogic { /** Returns a new renew price for the pricer. */ @SuppressWarnings("unused") - public FeesAndCredits getRenewPrice( - Registry registry, String domainName, DateTime dateTime, int years) throws EppException { + FeesAndCredits getRenewPrice(Registry registry, String domainName, DateTime dateTime, int years) + throws EppException { DomainPrices domainPrices = getPricesForDomainName(domainName, dateTime); BigDecimal renewCost = domainPrices.getRenewCost().multipliedBy(years).getAmount(); return customLogic.customizeRenewPrice( @@ -123,7 +123,7 @@ public final class DomainPricingLogic { } /** Returns a new restore price for the pricer. */ - public FeesAndCredits getRestorePrice( + FeesAndCredits getRestorePrice( Registry registry, String domainName, DateTime dateTime, boolean isExpired) throws EppException { DomainPrices domainPrices = getPricesForDomainName(domainName, dateTime); @@ -147,7 +147,7 @@ public final class DomainPricingLogic { } /** Returns a new transfer price for the pricer. */ - public FeesAndCredits getTransferPrice(Registry registry, String domainName, DateTime dateTime) + FeesAndCredits getTransferPrice(Registry registry, String domainName, DateTime dateTime) throws EppException { DomainPrices domainPrices = getPricesForDomainName(domainName, dateTime); return customLogic.customizeTransferPrice( @@ -168,7 +168,7 @@ public final class DomainPricingLogic { } /** Returns a new update price for the pricer. */ - public FeesAndCredits getUpdatePrice(Registry registry, String domainName, DateTime dateTime) + FeesAndCredits getUpdatePrice(Registry registry, String domainName, DateTime dateTime) throws EppException { CurrencyUnit currency = registry.getCurrency(); BaseFee feeOrCredit = Fee.create(zeroInCurrency(currency), FeeType.UPDATE, false); @@ -191,16 +191,20 @@ public final class DomainPricingLogic { throws EppException { if (allocationToken.isPresent() && allocationToken.get().getDiscountFraction() != 0.0 - && domainPrices.isPremium()) { + && domainPrices.isPremium() + && !allocationToken.get().shouldDiscountPremiums()) { throw new AllocationTokenInvalidForPremiumNameException(); } Money oneYearCreateCost = domainPrices.getCreateCost(); Money totalDomainCreateCost = oneYearCreateCost.multipliedBy(years); - // If a discount is applicable, apply it only to the first year + + // Apply the allocation token discount, if applicable. if (allocationToken.isPresent()) { + int discountedYears = Math.min(years, allocationToken.get().getDiscountYears()); Money discount = oneYearCreateCost.multipliedBy( - allocationToken.get().getDiscountFraction(), RoundingMode.HALF_UP); + discountedYears * allocationToken.get().getDiscountFraction(), + RoundingMode.HALF_EVEN); totalDomainCreateCost = totalDomainCreateCost.minus(discount); } return totalDomainCreateCost; @@ -209,7 +213,7 @@ public final class DomainPricingLogic { /** An allocation token was provided that is invalid for premium domains. */ public static class AllocationTokenInvalidForPremiumNameException extends CommandUseErrorException { - public AllocationTokenInvalidForPremiumNameException() { + AllocationTokenInvalidForPremiumNameException() { super("A nonzero discount code cannot be applied to premium domains"); } } diff --git a/core/src/main/java/google/registry/model/BackupGroupRoot.java b/core/src/main/java/google/registry/model/BackupGroupRoot.java index 7911784d3..9334cc0ea 100644 --- a/core/src/main/java/google/registry/model/BackupGroupRoot.java +++ b/core/src/main/java/google/registry/model/BackupGroupRoot.java @@ -14,7 +14,6 @@ package google.registry.model; -import com.google.common.annotations.VisibleForTesting; import javax.persistence.Access; import javax.persistence.AccessType; import javax.persistence.MappedSuperclass; @@ -30,6 +29,7 @@ import javax.xml.bind.annotation.XmlTransient; */ @MappedSuperclass public abstract class BackupGroupRoot extends ImmutableObject { + /** * An automatically managed timestamp of when this object was last written to Datastore. * @@ -40,7 +40,6 @@ public abstract class BackupGroupRoot extends ImmutableObject { // Prevents subclasses from unexpectedly accessing as property (e.g., HostResource), which would // require an unnecessary non-private setter method. @Access(AccessType.FIELD) - @VisibleForTesting UpdateAutoTimestamp updateTimestamp = UpdateAutoTimestamp.create(null); /** Get the {@link UpdateAutoTimestamp} for this entity. */ diff --git a/core/src/main/java/google/registry/model/domain/DomainContent.java b/core/src/main/java/google/registry/model/domain/DomainContent.java index 58f06b671..b24ca950f 100644 --- a/core/src/main/java/google/registry/model/domain/DomainContent.java +++ b/core/src/main/java/google/registry/model/domain/DomainContent.java @@ -534,8 +534,10 @@ public class DomainContent extends EppResource /** Loads and returns the fully qualified host names of all linked nameservers. */ public ImmutableSortedSet loadNameserverHostNames() { - return ofy().load() - .keys(getNameservers().stream().map(VKey::getOfyKey).collect(toImmutableSet())).values() + return ofy() + .load() + .keys(getNameservers().stream().map(VKey::getOfyKey).collect(toImmutableSet())) + .values() .stream() .map(HostResource::getHostName) .collect(toImmutableSortedSet(Ordering.natural())); diff --git a/core/src/main/java/google/registry/model/domain/DomainHistory.java b/core/src/main/java/google/registry/model/domain/DomainHistory.java index 197d5e4f1..8858a3441 100644 --- a/core/src/main/java/google/registry/model/domain/DomainHistory.java +++ b/core/src/main/java/google/registry/model/domain/DomainHistory.java @@ -38,10 +38,10 @@ import javax.persistence.JoinTable; @Entity @javax.persistence.Table( indexes = { - @javax.persistence.Index(columnList = "creationTime"), - @javax.persistence.Index(columnList = "historyRegistrarId"), - @javax.persistence.Index(columnList = "historyType"), - @javax.persistence.Index(columnList = "historyModificationTime") + @javax.persistence.Index(columnList = "creationTime"), + @javax.persistence.Index(columnList = "historyRegistrarId"), + @javax.persistence.Index(columnList = "historyType"), + @javax.persistence.Index(columnList = "historyModificationTime") }) public class DomainHistory extends HistoryEntry { // Store DomainContent instead of DomainBase so we don't pick up its @Id diff --git a/core/src/main/java/google/registry/model/domain/token/AllocationToken.java b/core/src/main/java/google/registry/model/domain/token/AllocationToken.java index 40f9ef4f7..87c26637d 100644 --- a/core/src/main/java/google/registry/model/domain/token/AllocationToken.java +++ b/core/src/main/java/google/registry/model/domain/token/AllocationToken.java @@ -29,12 +29,14 @@ import com.google.common.base.Strings; import com.google.common.collect.ImmutableMultimap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableSortedMap; +import com.google.common.collect.Range; import com.googlecode.objectify.Key; import com.googlecode.objectify.annotation.Embed; import com.googlecode.objectify.annotation.Entity; import com.googlecode.objectify.annotation.Id; import com.googlecode.objectify.annotation.Index; import com.googlecode.objectify.annotation.Mapify; +import com.googlecode.objectify.annotation.OnLoad; import google.registry.flows.EppException; import google.registry.flows.domain.DomainFlowUtils; import google.registry.model.BackupGroupRoot; @@ -108,9 +110,22 @@ public class AllocationToken extends BackupGroupRoot implements Buildable { */ double discountFraction; + /** Whether the discount fraction (if any) also applies to premium names. Defaults to false. */ + boolean discountPremiums; + + /** Up to how many years of initial creation receive the discount (if any). Defaults to 1. */ + int discountYears = 1; + /** The type of the token, either single-use or unlimited-use. */ - // TODO(b/130301183): this should not be nullable, we can remove this once we're sure it isn't - @Nullable TokenType tokenType; + TokenType tokenType; + + // TODO: Remove onLoad once all allocation tokens are migrated to have a discountYears of 1. + @OnLoad + void onLoad() { + if (discountYears == 0) { + discountYears = 1; + } + } /** * Promotional token validity periods. @@ -146,8 +161,8 @@ public class AllocationToken extends BackupGroupRoot implements Buildable { return token; } - public Key getRedemptionHistoryEntry() { - return redemptionHistoryEntry; + public Optional> getRedemptionHistoryEntry() { + return Optional.ofNullable(redemptionHistoryEntry); } public boolean isRedeemed() { @@ -174,6 +189,16 @@ public class AllocationToken extends BackupGroupRoot implements Buildable { return discountFraction; } + public boolean shouldDiscountPremiums() { + return discountPremiums; + } + + public int getDiscountYears() { + // Allocation tokens created prior to the addition of the discountYears field will have a value + // of 0 for it, but it should be the default value of 1 to retain the previous behavior. + return Math.max(1, discountYears); + } + public TokenType getTokenType() { return tokenType; } @@ -193,6 +218,7 @@ public class AllocationToken extends BackupGroupRoot implements Buildable { /** A builder for constructing {@link AllocationToken} objects, since they are immutable. */ public static class Builder extends Buildable.Builder { + public Builder() {} private Builder(AllocationToken instance) { @@ -210,6 +236,12 @@ public class AllocationToken extends BackupGroupRoot implements Buildable { getInstance().redemptionHistoryEntry == null || TokenType.SINGLE_USE.equals(getInstance().tokenType), "Redemption history entry can only be specified for SINGLE_USE tokens"); + checkArgument( + getInstance().discountFraction > 0 || !getInstance().discountPremiums, + "Discount premiums can only be specified along with a discount fraction"); + checkArgument( + getInstance().discountFraction > 0 || getInstance().discountYears == 1, + "Discount years can only be specified along with a discount fraction"); if (getInstance().domainName != null) { try { DomainFlowUtils.validateDomainName(getInstance().domainName); @@ -258,10 +290,26 @@ public class AllocationToken extends BackupGroupRoot implements Buildable { } public Builder setDiscountFraction(double discountFraction) { + checkArgument( + Range.closed(0.0d, 1.0d).contains(discountFraction), + "Discount fraction must be between 0 and 1 inclusive"); getInstance().discountFraction = discountFraction; return this; } + public Builder setDiscountPremiums(boolean discountPremiums) { + getInstance().discountPremiums = discountPremiums; + return this; + } + + public Builder setDiscountYears(int discountYears) { + checkArgument( + Range.closed(1, 10).contains(discountYears), + "Discount years must be between 1 and 10 inclusive"); + getInstance().discountYears = discountYears; + return this; + } + public Builder setTokenType(TokenType tokenType) { checkState(getInstance().tokenType == null, "Token type can only be set once"); getInstance().tokenType = tokenType; diff --git a/core/src/main/java/google/registry/tools/GenerateAllocationTokensCommand.java b/core/src/main/java/google/registry/tools/GenerateAllocationTokensCommand.java index d49a0d610..01e12eb1d 100644 --- a/core/src/main/java/google/registry/tools/GenerateAllocationTokensCommand.java +++ b/core/src/main/java/google/registry/tools/GenerateAllocationTokensCommand.java @@ -115,7 +115,20 @@ class GenerateAllocationTokensCommand implements CommandWithRemoteApi { description = "A discount off the base price for the first year between 0.0 and 1.0. Default is 0.0," + " i.e. no discount.") - private double discountFraction; + private Double discountFraction; + + @Parameter( + names = {"--discount_premiums"}, + description = + "Whether the discount is valid for premium names in addition to standard ones. Default" + + " is false.", + arity = 1) + private Boolean discountPremiums; + + @Parameter( + names = {"--discount_years"}, + description = "The number of years the discount applies for. Default is 1, max value is 10.") + private Integer discountYears; @Parameter( names = "--token_status_transitions", @@ -170,8 +183,10 @@ class GenerateAllocationTokensCommand implements CommandWithRemoteApi { .setToken(t) .setTokenType(tokenType == null ? SINGLE_USE : tokenType) .setAllowedClientIds(ImmutableSet.copyOf(nullToEmpty(allowedClientIds))) - .setAllowedTlds(ImmutableSet.copyOf(nullToEmpty(allowedTlds))) - .setDiscountFraction(discountFraction); + .setAllowedTlds(ImmutableSet.copyOf(nullToEmpty(allowedTlds))); + Optional.ofNullable(discountFraction).ifPresent(token::setDiscountFraction); + Optional.ofNullable(discountPremiums).ifPresent(token::setDiscountPremiums); + Optional.ofNullable(discountYears).ifPresent(token::setDiscountYears); Optional.ofNullable(tokenStatusTransitions) .ifPresent(token::setTokenStatusTransitions); Optional.ofNullable(domainNames) diff --git a/core/src/main/java/google/registry/tools/GetAllocationTokenCommand.java b/core/src/main/java/google/registry/tools/GetAllocationTokenCommand.java index 4ed94bb04..c70c6da4d 100644 --- a/core/src/main/java/google/registry/tools/GetAllocationTokenCommand.java +++ b/core/src/main/java/google/registry/tools/GetAllocationTokenCommand.java @@ -27,7 +27,7 @@ import google.registry.model.domain.DomainBase; import google.registry.model.domain.token.AllocationToken; import java.util.Collection; import java.util.List; -import java.util.Objects; +import java.util.Optional; /** Command to show allocation tokens. */ @Parameters(separators = " =", commandDescription = "Show allocation token(s)") @@ -55,11 +55,11 @@ final class GetAllocationTokenCommand implements CommandWithRemoteApi { if (loadedTokens.containsKey(token)) { AllocationToken loadedToken = loadedTokens.get(token); System.out.println(loadedToken.toString()); - if (loadedToken.getRedemptionHistoryEntry() == null) { + if (!loadedToken.getRedemptionHistoryEntry().isPresent()) { System.out.printf("Token %s was not redeemed.\n", token); } else { DomainBase domain = - domains.get(loadedToken.getRedemptionHistoryEntry().getParent()); + domains.get(loadedToken.getRedemptionHistoryEntry().get().getParent()); if (domain == null) { System.out.printf("ERROR: Token %s was redeemed but domain can't be loaded.\n", token); } else { @@ -80,7 +80,8 @@ final class GetAllocationTokenCommand implements CommandWithRemoteApi { ImmutableList> domainKeys = tokens.stream() .map(AllocationToken::getRedemptionHistoryEntry) - .filter(Objects::nonNull) + .filter(Optional::isPresent) + .map(Optional::get) .map(Key::getParent) .collect(toImmutableList()); ImmutableMap.Builder, DomainBase> domainsBuilder = new ImmutableMap.Builder<>(); diff --git a/core/src/main/java/google/registry/tools/UpdateAllocationTokensCommand.java b/core/src/main/java/google/registry/tools/UpdateAllocationTokensCommand.java index b70bef123..48dc11270 100644 --- a/core/src/main/java/google/registry/tools/UpdateAllocationTokensCommand.java +++ b/core/src/main/java/google/registry/tools/UpdateAllocationTokensCommand.java @@ -66,6 +66,19 @@ final class UpdateAllocationTokensCommand extends UpdateOrDeleteAllocationTokens + "i.e. no discount.") private Double discountFraction; + @Parameter( + names = {"--discount_premiums"}, + description = + "Whether the discount is valid for premium names in addition to standard ones. Default" + + " is false.", + arity = 1) + private Boolean discountPremiums; + + @Parameter( + names = {"--discount_years"}, + description = "The number of years the discount applies for. Default is 1, max value is 10.") + private Integer discountYears; + @Parameter( names = "--token_status_transitions", converter = TokenStatusTransitions.class, @@ -122,6 +135,8 @@ final class UpdateAllocationTokensCommand extends UpdateOrDeleteAllocationTokens Optional.ofNullable(allowedTlds) .ifPresent(tlds -> builder.setAllowedTlds(ImmutableSet.copyOf(tlds))); Optional.ofNullable(discountFraction).ifPresent(builder::setDiscountFraction); + Optional.ofNullable(discountPremiums).ifPresent(builder::setDiscountPremiums); + Optional.ofNullable(discountYears).ifPresent(builder::setDiscountYears); Optional.ofNullable(tokenStatusTransitions).ifPresent(builder::setTokenStatusTransitions); return builder.build(); } diff --git a/core/src/test/java/google/registry/flows/domain/DomainCheckFlowTest.java b/core/src/test/java/google/registry/flows/domain/DomainCheckFlowTest.java index 380ffc2c7..1eb1363b8 100644 --- a/core/src/test/java/google/registry/flows/domain/DomainCheckFlowTest.java +++ b/core/src/test/java/google/registry/flows/domain/DomainCheckFlowTest.java @@ -282,13 +282,14 @@ class DomainCheckFlowTest extends ResourceCheckFlowTestCasenaturalOrder() .put(START_OF_TIME, TokenStatus.NOT_STARTED) @@ -300,6 +301,69 @@ class DomainCheckFlowTest extends ResourceCheckFlowTestCasenaturalOrder() + .put(START_OF_TIME, TokenStatus.NOT_STARTED) + .put(clock.nowUtc().minusDays(1), TokenStatus.VALID) + .put(clock.nowUtc().plusDays(1), TokenStatus.ENDED) + .build()) + .build()); + setEppInput( + "domain_check_allocationtoken_promotion.xml", ImmutableMap.of("DOMAIN", "rich.example")); + runFlowAssertResponse( + loadFile( + "domain_check_allocationtoken_promotion_response.xml", + new ImmutableMap.Builder() + .put("DOMAIN", "rich.example") + .put("COST_1YR", "10.00") + .put("COST_2YR", "20.00") + .put("COST_5YR", "230.00") + .put("FEE_CLASS", "premium") + .build())); + } + + @Test + void testSuccess_allocationTokenPromotion_multiYear() throws Exception { + createTld("tld"); + persistResource( + new AllocationToken.Builder() + .setToken("abc123") + .setTokenType(SINGLE_USE) + .setDomainName("single.tld") + .setDiscountFraction(0.444) + .setDiscountYears(2) + .setTokenStatusTransitions( + ImmutableSortedMap.naturalOrder() + .put(START_OF_TIME, TokenStatus.NOT_STARTED) + .put(clock.nowUtc().minusDays(1), TokenStatus.VALID) + .put(clock.nowUtc().plusDays(1), TokenStatus.ENDED) + .build()) + .build()); + setEppInput( + "domain_check_allocationtoken_promotion.xml", ImmutableMap.of("DOMAIN", "single.tld")); + runFlowAssertResponse( + loadFile( + "domain_check_allocationtoken_promotion_response.xml", + new ImmutableMap.Builder() + .put("DOMAIN", "single.tld") + .put("COST_1YR", "7.23") + .put("COST_2YR", "14.46") + .put("COST_5YR", "53.46") + .put("FEE_CLASS", "") + .build())); + } + @Test void testSuccess_promotionNotActive() throws Exception { createTld("example"); diff --git a/core/src/test/java/google/registry/flows/domain/DomainCreateFlowTest.java b/core/src/test/java/google/registry/flows/domain/DomainCreateFlowTest.java index 3f2ff2abd..a604de315 100644 --- a/core/src/test/java/google/registry/flows/domain/DomainCreateFlowTest.java +++ b/core/src/test/java/google/registry/flows/domain/DomainCreateFlowTest.java @@ -68,6 +68,7 @@ import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableSortedMap; import com.google.common.collect.Iterables; +import com.google.common.collect.Ordering; import com.googlecode.objectify.Key; import google.registry.config.RegistryConfig; import google.registry.flows.EppException; @@ -150,12 +151,12 @@ import google.registry.model.domain.rgp.GracePeriodStatus; import google.registry.model.domain.secdns.DelegationSignerData; import google.registry.model.domain.token.AllocationToken; import google.registry.model.domain.token.AllocationToken.TokenStatus; -import google.registry.model.domain.token.AllocationToken.TokenType; import google.registry.model.poll.PendingActionNotificationResponse.DomainPendingActionNotificationResponse; import google.registry.model.poll.PollMessage; import google.registry.model.registrar.Registrar; import google.registry.model.registrar.Registrar.State; import google.registry.model.registry.Registry; +import google.registry.model.registry.Registry.TldState; import google.registry.model.registry.Registry.TldType; import google.registry.model.reporting.DomainTransactionRecord; import google.registry.model.reporting.DomainTransactionRecord.TransactionReportField; @@ -435,7 +436,9 @@ class DomainCreateFlowTest extends ResourceFlowTestCase(Ordering.natural()) + .put(START_OF_TIME, PREDELEGATION) + .put(DateTime.parse("1999-01-01T00:00:00Z"), QUIET_PERIOD) + .put(DateTime.parse("1999-07-01T00:00:00Z"), START_DATE_SUNRISE) + .put(DateTime.parse("2000-01-01T00:00:00Z"), GENERAL_AVAILABILITY) + .build()) .build()); + // The anchor tenant is created during the quiet period, on 1999-04-03. setEppInput("domain_create_anchor_allocationtoken.xml"); persistContactsAndHosts(); runFlowAssertResponse(loadFile("domain_create_anchor_response.xml")); @@ -1210,7 +1223,8 @@ class DomainCreateFlowTest extends ResourceFlowTestCasenaturalOrder() @@ -1274,7 +1289,9 @@ class DomainCreateFlowTest extends ResourceFlowTestCasenaturalOrder() + .put(START_OF_TIME, TokenStatus.NOT_STARTED) + .put(clock.nowUtc().plusMillis(1), TokenStatus.VALID) + .put(clock.nowUtc().plusSeconds(1), TokenStatus.ENDED) + .build()) + .build()); + clock.advanceOneMilli(); + setEppInput( + "domain_create_allocationtoken.xml", + ImmutableMap.of("DOMAIN", "example.tld", "YEARS", "5")); + runFlowAssertResponse( + loadFile( + "domain_create_response_wildcard.xml", + new ImmutableMap.Builder() + .put("DOMAIN", "example.tld") + .put("CRDATE", "1999-04-03T22:00:00.0Z") + .put("EXDATE", "2004-04-03T22:00:00.0Z") + .build())); + BillingEvent.OneTime billingEvent = + Iterables.getOnlyElement(ofy().load().type(BillingEvent.OneTime.class)); + assertThat(billingEvent.getTargetId()).isEqualTo("example.tld"); + assertThat(billingEvent.getCost()).isEqualTo(expectedPrice); + } + + @Test + void testSuccess_allocationToken_multiYearDiscount_worksForPremiums() throws Exception { createTld("example"); persistContactsAndHosts(); persistResource( new AllocationToken.Builder() .setToken("abc123") - .setTokenType(TokenType.UNLIMITED_USE) + .setTokenType(SINGLE_USE) + .setDomainName("rich.example") + .setDiscountFraction(0.98) + .setDiscountYears(2) + .setDiscountPremiums(true) + .setTokenStatusTransitions( + ImmutableSortedMap.naturalOrder() + .put(START_OF_TIME, TokenStatus.NOT_STARTED) + .put(clock.nowUtc().plusMillis(1), TokenStatus.VALID) + .put(clock.nowUtc().plusSeconds(1), TokenStatus.ENDED) + .build()) + .build()); + clock.advanceOneMilli(); + setEppInput( + "domain_create_premium_allocationtoken.xml", + ImmutableMap.of("YEARS", "3", "FEE", "104.00")); + runFlowAssertResponse( + loadFile( + "domain_create_response_premium.xml", + ImmutableMap.of("EXDATE", "2002-04-03T22:00:00.0Z", "FEE", "104.00"))); + BillingEvent.OneTime billingEvent = + Iterables.getOnlyElement(ofy().load().type(BillingEvent.OneTime.class)); + assertThat(billingEvent.getTargetId()).isEqualTo("rich.example"); + // 1yr @ $100 + 2yrs @ $100 * (1 - 0.98) = $104 + assertThat(billingEvent.getCost()).isEqualTo(Money.of(USD, 104.00)); + } + + @Test + void testSuccess_allocationToken_singleYearDiscount_worksForPremiums() throws Exception { + createTld("example"); + persistContactsAndHosts(); + persistResource( + new AllocationToken.Builder() + .setToken("abc123") + .setTokenType(SINGLE_USE) + .setDomainName("rich.example") + .setDiscountFraction(0.95555) + .setDiscountPremiums(true) + .setTokenStatusTransitions( + ImmutableSortedMap.naturalOrder() + .put(START_OF_TIME, TokenStatus.NOT_STARTED) + .put(clock.nowUtc().plusMillis(1), TokenStatus.VALID) + .put(clock.nowUtc().plusSeconds(1), TokenStatus.ENDED) + .build()) + .build()); + clock.advanceOneMilli(); + setEppInput( + "domain_create_premium_allocationtoken.xml", + ImmutableMap.of("YEARS", "3", "FEE", "204.44")); + runFlowAssertResponse( + loadFile( + "domain_create_response_premium.xml", + ImmutableMap.of("EXDATE", "2002-04-03T22:00:00.0Z", "FEE", "204.44"))); + BillingEvent.OneTime billingEvent = + Iterables.getOnlyElement(ofy().load().type(BillingEvent.OneTime.class)); + assertThat(billingEvent.getTargetId()).isEqualTo("rich.example"); + // 2yrs @ $100 + 1yr @ $100 * (1 - 0.95555) = $204.44 + assertThat(billingEvent.getCost()).isEqualTo(Money.of(USD, 204.44)); + } + + @Test + void testSuccess_promotionDoesNotApplyToPremiumPrice() { + // Discounts only apply to premium domains if the token is explicitly configured to allow it. + createTld("example"); + persistContactsAndHosts(); + persistResource( + new AllocationToken.Builder() + .setToken("abc123") + .setTokenType(UNLIMITED_USE) .setDiscountFraction(0.5) .setTokenStatusTransitions( ImmutableSortedMap.naturalOrder() @@ -1301,7 +1435,9 @@ class DomainCreateFlowTest extends ResourceFlowTestCasenaturalOrder() @@ -1322,7 +1458,9 @@ class DomainCreateFlowTest extends ResourceFlowTestCasenaturalOrder() .put(START_OF_TIME, NOT_STARTED) @@ -155,7 +157,7 @@ class AllocationTokenTest extends EntityTestCase { } @Test - void testBuild_invalidTLD() { + void testBuild_invalidTld() { IllegalArgumentException thrown = assertThrows( IllegalArgumentException.class, @@ -268,6 +270,50 @@ class AllocationTokenTest extends EntityTestCase { assertTerminal(CANCELLED); } + @Test + void testSetDiscountFractionTooHigh() { + IllegalArgumentException thrown = + assertThrows( + IllegalArgumentException.class, + () -> new AllocationToken.Builder().setDiscountFraction(1.1)); + assertThat(thrown) + .hasMessageThat() + .isEqualTo("Discount fraction must be between 0 and 1 inclusive"); + } + + @Test + void testSetDiscountFractionTooLow() { + IllegalArgumentException thrown = + assertThrows( + IllegalArgumentException.class, + () -> new AllocationToken.Builder().setDiscountFraction(-.0001)); + assertThat(thrown) + .hasMessageThat() + .isEqualTo("Discount fraction must be between 0 and 1 inclusive"); + } + + @Test + void testSetDiscountYearsTooHigh() { + IllegalArgumentException thrown = + assertThrows( + IllegalArgumentException.class, + () -> new AllocationToken.Builder().setDiscountYears(11)); + assertThat(thrown) + .hasMessageThat() + .isEqualTo("Discount years must be between 1 and 10 inclusive"); + } + + @Test + void testSetDiscountYearsTooLow() { + IllegalArgumentException thrown = + assertThrows( + IllegalArgumentException.class, + () -> new AllocationToken.Builder().setDiscountYears(0)); + assertThat(thrown) + .hasMessageThat() + .isEqualTo("Discount years must be between 1 and 10 inclusive"); + } + @Test void testBuild_noTokenType() { IllegalArgumentException thrown = @@ -295,6 +341,38 @@ class AllocationTokenTest extends EntityTestCase { assertThat(thrown).hasMessageThat().isEqualTo("Token must not be blank"); } + @Test + void testBuild_discountPremiumsRequiresDiscountFraction() { + IllegalArgumentException thrown = + assertThrows( + IllegalArgumentException.class, + () -> + new AllocationToken.Builder() + .setToken("abc") + .setTokenType(SINGLE_USE) + .setDiscountPremiums(true) + .build()); + assertThat(thrown) + .hasMessageThat() + .isEqualTo("Discount premiums can only be specified along with a discount fraction"); + } + + @Test + void testBuild_discountYearsRequiresDiscountFraction() { + IllegalArgumentException thrown = + assertThrows( + IllegalArgumentException.class, + () -> + new AllocationToken.Builder() + .setToken("abc") + .setTokenType(SINGLE_USE) + .setDiscountYears(2) + .build()); + assertThat(thrown) + .hasMessageThat() + .isEqualTo("Discount years can only be specified along with a discount fraction"); + } + private void assertBadInitialTransition(TokenStatus status) { assertBadTransition( ImmutableSortedMap.naturalOrder() diff --git a/core/src/test/java/google/registry/model/history/ContactHistoryTest.java b/core/src/test/java/google/registry/model/history/ContactHistoryTest.java index 6bb484d7e..edee61dae 100644 --- a/core/src/test/java/google/registry/model/history/ContactHistoryTest.java +++ b/core/src/test/java/google/registry/model/history/ContactHistoryTest.java @@ -69,9 +69,11 @@ public class ContactHistoryTest extends EntityTestCase { } static void assertContactHistoriesEqual(ContactHistory one, ContactHistory two) { - assertAboutImmutableObjects().that(one) + assertAboutImmutableObjects() + .that(one) .isEqualExceptFields(two, "contactBase", "contactRepoId", "parent"); - assertAboutImmutableObjects().that(one.getContactBase()) + assertAboutImmutableObjects() + .that(one.getContactBase()) .isEqualExceptFields(two.getContactBase(), "repoId"); } } diff --git a/core/src/test/java/google/registry/model/history/DomainHistoryTest.java b/core/src/test/java/google/registry/model/history/DomainHistoryTest.java index 86a36e7d3..d6ca11d33 100644 --- a/core/src/test/java/google/registry/model/history/DomainHistoryTest.java +++ b/core/src/test/java/google/registry/model/history/DomainHistoryTest.java @@ -83,7 +83,8 @@ public class DomainHistoryTest extends EntityTestCase { } static void assertDomainHistoriesEqual(DomainHistory one, DomainHistory two) { - assertAboutImmutableObjects().that(one) + assertAboutImmutableObjects() + .that(one) .isEqualExceptFields(two, "domainContent", "domainRepoId", "parent"); } } diff --git a/core/src/test/java/google/registry/testing/DatastoreHelper.java b/core/src/test/java/google/registry/testing/DatastoreHelper.java index 6407c8fcf..a355d697c 100644 --- a/core/src/test/java/google/registry/testing/DatastoreHelper.java +++ b/core/src/test/java/google/registry/testing/DatastoreHelper.java @@ -26,6 +26,7 @@ import static google.registry.config.RegistryConfig.getContactAndHostRoidSuffix; import static google.registry.config.RegistryConfig.getContactAutomaticTransferLength; import static google.registry.model.EppResourceUtils.createDomainRepoId; import static google.registry.model.EppResourceUtils.createRepoId; +import static google.registry.model.ImmutableObjectSubject.immutableObjectCorrespondence; import static google.registry.model.ResourceTransferUtils.createTransferResponse; import static google.registry.model.ofy.ObjectifyService.ofy; import static google.registry.model.registry.Registry.TldState.GENERAL_AVAILABILITY; @@ -71,6 +72,7 @@ import google.registry.model.domain.DomainAuthInfo; import google.registry.model.domain.DomainBase; import google.registry.model.domain.GracePeriod; import google.registry.model.domain.rgp.GracePeriodStatus; +import google.registry.model.domain.token.AllocationToken; import google.registry.model.eppcommon.AuthInfo.PasswordAuth; import google.registry.model.eppcommon.StatusValue; import google.registry.model.eppcommon.Trid; @@ -832,6 +834,12 @@ public class DatastoreHelper { .collect(onlyElement()); } + public static void assertAllocationTokens(AllocationToken... expectedTokens) { + assertThat(ofy().load().type(AllocationToken.class).list()) + .comparingElementsUsing(immutableObjectCorrespondence("updateTimestamp", "creationTime")) + .containsExactlyElementsIn(expectedTokens); + } + /** Returns a newly allocated, globally unique domain repoId of the format HEX-TLD. */ public static String generateNewDomainRoid(String tld) { return createDomainRepoId(ObjectifyService.allocateId(), tld); diff --git a/core/src/test/java/google/registry/tools/GenerateAllocationTokensCommandTest.java b/core/src/test/java/google/registry/tools/GenerateAllocationTokensCommandTest.java index 302ba1939..0de26760c 100644 --- a/core/src/test/java/google/registry/tools/GenerateAllocationTokensCommandTest.java +++ b/core/src/test/java/google/registry/tools/GenerateAllocationTokensCommandTest.java @@ -18,6 +18,7 @@ import static com.google.common.truth.Truth.assertThat; import static google.registry.model.domain.token.AllocationToken.TokenType.SINGLE_USE; import static google.registry.model.domain.token.AllocationToken.TokenType.UNLIMITED_USE; import static google.registry.model.ofy.ObjectifyService.ofy; +import static google.registry.testing.DatastoreHelper.assertAllocationTokens; import static google.registry.testing.DatastoreHelper.createTld; import static google.registry.testing.DatastoreHelper.persistResource; import static google.registry.util.DateTimeUtils.START_OF_TIME; @@ -32,11 +33,9 @@ import static org.mockito.Mockito.verify; import com.beust.jcommander.ParameterException; import com.google.appengine.tools.remoteapi.RemoteApiException; import com.google.common.base.Joiner; -import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableSortedMap; import com.google.common.collect.Iterables; -import com.google.common.collect.Maps; import com.google.common.io.Files; import com.googlecode.objectify.Key; import google.registry.model.domain.token.AllocationToken; @@ -160,6 +159,8 @@ class GenerateAllocationTokensCommandTest extends CommandTestCasenaturalOrder() .put(START_OF_TIME, TokenStatus.NOT_STARTED) @@ -309,26 +312,6 @@ class GenerateAllocationTokensCommandTest extends CommandTestCase actualTokens = - Maps.uniqueIndex(ofy().load().type(AllocationToken.class), AllocationToken::getToken); - assertThat(actualTokens).hasSize(expectedTokens.length); - for (AllocationToken expectedToken : expectedTokens) { - AllocationToken match = actualTokens.get(expectedToken.getToken()); - assertThat(match).isNotNull(); - assertThat(match.getRedemptionHistoryEntry()) - .isEqualTo(expectedToken.getRedemptionHistoryEntry()); - assertThat(match.getAllowedClientIds()).isEqualTo(expectedToken.getAllowedClientIds()); - assertThat(match.getAllowedTlds()).isEqualTo(expectedToken.getAllowedTlds()); - assertThat(match.getDiscountFraction()).isEqualTo(expectedToken.getDiscountFraction()); - assertThat(match.getTokenStatusTransitions()) - .isEqualTo(expectedToken.getTokenStatusTransitions()); - assertThat(match.getTokenType()).isEqualTo(expectedToken.getTokenType()); - } - } - private AllocationToken createToken( String token, @Nullable Key redemptionHistoryEntry, diff --git a/core/src/test/java/google/registry/tools/UpdateAllocationTokensCommandTest.java b/core/src/test/java/google/registry/tools/UpdateAllocationTokensCommandTest.java index cf19272f7..8861a0593 100644 --- a/core/src/test/java/google/registry/tools/UpdateAllocationTokensCommandTest.java +++ b/core/src/test/java/google/registry/tools/UpdateAllocationTokensCommandTest.java @@ -77,6 +77,24 @@ class UpdateAllocationTokensCommandTest extends CommandTestCase + + + + %DOMAIN% + + + + + abc123 + + + + %DOMAIN% + USD + create + 1 + + + %DOMAIN% + USD + create + 2 + + + %DOMAIN% + USD + create + 5 + + + + ABC-12345 + + diff --git a/core/src/test/resources/google/registry/flows/domain/domain_check_allocationtoken_promotion_response.xml b/core/src/test/resources/google/registry/flows/domain/domain_check_allocationtoken_promotion_response.xml new file mode 100644 index 000000000..8d5d2d93d --- /dev/null +++ b/core/src/test/resources/google/registry/flows/domain/domain_check_allocationtoken_promotion_response.xml @@ -0,0 +1,47 @@ + + + + + Command completed successfully + + + + + %DOMAIN% + + + + + + + %DOMAIN% + USD + create + 1 + %COST_1YR% + %FEE_CLASS% + + + %DOMAIN% + USD + create + 2 + %COST_2YR% + %FEE_CLASS% + + + %DOMAIN% + USD + create + 5 + %COST_5YR% + %FEE_CLASS% + + + + + ABC-12345 + server-trid + + + diff --git a/core/src/test/resources/google/registry/flows/domain/domain_create_allocationtoken.xml b/core/src/test/resources/google/registry/flows/domain/domain_create_allocationtoken.xml index cb1b09d8c..eb68b9062 100644 --- a/core/src/test/resources/google/registry/flows/domain/domain_create_allocationtoken.xml +++ b/core/src/test/resources/google/registry/flows/domain/domain_create_allocationtoken.xml @@ -4,7 +4,7 @@ %DOMAIN% - 2 + %YEARS% ns1.example.net ns2.example.net diff --git a/core/src/test/resources/google/registry/flows/domain/domain_create_premium_allocationtoken.xml b/core/src/test/resources/google/registry/flows/domain/domain_create_premium_allocationtoken.xml index 80ccae97e..abd13d5c1 100644 --- a/core/src/test/resources/google/registry/flows/domain/domain_create_premium_allocationtoken.xml +++ b/core/src/test/resources/google/registry/flows/domain/domain_create_premium_allocationtoken.xml @@ -4,7 +4,7 @@ rich.example - 2 + %YEARS% ns1.example.net ns2.example.net @@ -25,7 +25,7 @@ USD - 193.5 + %FEE% ABC-12345 diff --git a/core/src/test/resources/google/registry/flows/domain/domain_create_response_premium.xml b/core/src/test/resources/google/registry/flows/domain/domain_create_response_premium.xml index fe5a489dc..044159c38 100644 --- a/core/src/test/resources/google/registry/flows/domain/domain_create_response_premium.xml +++ b/core/src/test/resources/google/registry/flows/domain/domain_create_response_premium.xml @@ -8,13 +8,13 @@ xmlns:domain="urn:ietf:params:xml:ns:domain-1.0"> rich.example 1999-04-03T22:00:00.0Z - 2001-04-03T22:00:00.0Z + %EXDATE% USD - 200.00 + %FEE% diff --git a/core/src/test/resources/google/registry/flows/domain/domain_create_response_wildcard.xml b/core/src/test/resources/google/registry/flows/domain/domain_create_response_wildcard.xml new file mode 100644 index 000000000..4593cba54 --- /dev/null +++ b/core/src/test/resources/google/registry/flows/domain/domain_create_response_wildcard.xml @@ -0,0 +1,19 @@ + + + + Command completed successfully + + + + %DOMAIN% + %CRDATE% + %EXDATE% + + + + ABC-12345 + server-trid + + + diff --git a/core/src/test/resources/google/registry/model/schema.txt b/core/src/test/resources/google/registry/model/schema.txt index 9eeb99263..304d36eed 100644 --- a/core/src/test/resources/google/registry/model/schema.txt +++ b/core/src/test/resources/google/registry/model/schema.txt @@ -231,12 +231,14 @@ class google.registry.model.domain.secdns.DelegationSignerData { } class google.registry.model.domain.token.AllocationToken { @Id java.lang.String token; + boolean discountPremiums; com.googlecode.objectify.Key redemptionHistoryEntry; double discountFraction; google.registry.model.CreateAutoTimestamp creationTime; google.registry.model.UpdateAutoTimestamp updateTimestamp; google.registry.model.common.TimedTransitionProperty tokenStatusTransitions; google.registry.model.domain.token.AllocationToken$TokenType tokenType; + int discountYears; java.lang.String domainName; java.util.Set allowedClientIds; java.util.Set allowedTlds; diff --git a/util/src/main/java/google/registry/util/CollectionUtils.java b/util/src/main/java/google/registry/util/CollectionUtils.java index ac2d2e6dd..2abb20a45 100644 --- a/util/src/main/java/google/registry/util/CollectionUtils.java +++ b/util/src/main/java/google/registry/util/CollectionUtils.java @@ -75,38 +75,39 @@ public class CollectionUtils { } /** Defensive copy helper for {@link Set}. */ - public static ImmutableSet nullSafeImmutableCopy(Set data) { + public static ImmutableSet nullSafeImmutableCopy(@Nullable Set data) { return data == null ? null : ImmutableSet.copyOf(data); } /** Defensive copy helper for {@link List}. */ - public static ImmutableList nullSafeImmutableCopy(List data) { + public static ImmutableList nullSafeImmutableCopy(@Nullable List data) { return data == null ? null : ImmutableList.copyOf(data); } /** Defensive copy helper for {@link Set}. */ - public static ImmutableSet nullToEmptyImmutableCopy(Set data) { + public static ImmutableSet nullToEmptyImmutableCopy(@Nullable Set data) { return data == null ? ImmutableSet.of() : ImmutableSet.copyOf(data); } /** Defensive copy helper for {@link Set}. */ - public static > - ImmutableSortedSet nullToEmptyImmutableSortedCopy(Set data) { + public static > ImmutableSortedSet nullToEmptyImmutableSortedCopy( + @Nullable Set data) { return data == null ? ImmutableSortedSet.of() : ImmutableSortedSet.copyOf(data); } /** Defensive copy helper for {@link SortedMap}. */ - public static ImmutableSortedMap nullToEmptyImmutableCopy(SortedMap data) { + public static ImmutableSortedMap nullToEmptyImmutableCopy( + @Nullable SortedMap data) { return data == null ? ImmutableSortedMap.of() : ImmutableSortedMap.copyOfSorted(data); } /** Defensive copy helper for {@link List}. */ - public static ImmutableList nullToEmptyImmutableCopy(List data) { + public static ImmutableList nullToEmptyImmutableCopy(@Nullable List data) { return data == null ? ImmutableList.of() : ImmutableList.copyOf(data); } /** Defensive copy helper for {@link Map}. */ - public static ImmutableMap nullToEmptyImmutableCopy(Map data) { + public static ImmutableMap nullToEmptyImmutableCopy(@Nullable Map data) { return data == null ? ImmutableMap.of() : ImmutableMap.copyOf(data); } diff --git a/util/src/main/java/google/registry/util/PreconditionsUtils.java b/util/src/main/java/google/registry/util/PreconditionsUtils.java index 73161e3d9..68f5fcf3b 100644 --- a/util/src/main/java/google/registry/util/PreconditionsUtils.java +++ b/util/src/main/java/google/registry/util/PreconditionsUtils.java @@ -29,33 +29,36 @@ public class PreconditionsUtils { * preferable to throw an IAE instead of an NPE, such as where we want an IAE to indicate that * it's just a bad argument/parameter and reserve NPEs for bugs and unexpected null values. */ - public static T checkArgumentNotNull(T reference) { + public static T checkArgumentNotNull(@Nullable T reference) { checkArgument(reference != null); return reference; } /** Checks whether the provided reference is null, throws IAE if it is, and returns it if not. */ - public static T checkArgumentNotNull(T reference, @Nullable Object errorMessage) { + public static T checkArgumentNotNull(@Nullable T reference, @Nullable Object errorMessage) { checkArgument(reference != null, errorMessage); return reference; } /** Checks whether the provided reference is null, throws IAE if it is, and returns it if not. */ public static T checkArgumentNotNull( - T reference, @Nullable String errorMessageTemplate, @Nullable Object... errorMessageArgs) { + @Nullable T reference, + @Nullable String errorMessageTemplate, + @Nullable Object... errorMessageArgs) { checkArgument(reference != null, errorMessageTemplate, errorMessageArgs); return reference; } /** Checks if the provided Optional is present, returns its value if so, and throws IAE if not. */ - public static T checkArgumentPresent(Optional reference) { + public static T checkArgumentPresent(@Nullable Optional reference) { checkArgumentNotNull(reference); checkArgument(reference.isPresent()); return reference.get(); } /** Checks if the provided Optional is present, returns its value if so, and throws IAE if not. */ - public static T checkArgumentPresent(Optional reference, @Nullable Object errorMessage) { + public static T checkArgumentPresent( + @Nullable Optional reference, @Nullable Object errorMessage) { checkArgumentNotNull(reference, errorMessage); checkArgument(reference.isPresent(), errorMessage); return reference.get(); @@ -63,7 +66,7 @@ public class PreconditionsUtils { /** Checks if the provided Optional is present, returns its value if so, and throws IAE if not. */ public static T checkArgumentPresent( - Optional reference, + @Nullable Optional reference, @Nullable String errorMessageTemplate, @Nullable Object... errorMessageArgs) { checkArgumentNotNull(reference, errorMessageTemplate, errorMessageArgs);