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);