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
This commit is contained in:
Ben McIlwain
2020-08-05 17:54:47 -04:00
committed by GitHub
parent 95f4ae0e3a
commit b2a78b5d68
25 changed files with 606 additions and 111 deletions
@@ -60,7 +60,7 @@ public final class DomainPricingLogic {
* <p>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");
}
}
@@ -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. */
@@ -534,8 +534,10 @@ public class DomainContent extends EppResource
/** Loads and returns the fully qualified host names of all linked nameservers. */
public ImmutableSortedSet<String> 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()));
@@ -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
@@ -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<HistoryEntry> getRedemptionHistoryEntry() {
return redemptionHistoryEntry;
public Optional<Key<HistoryEntry>> 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<AllocationToken> {
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;
@@ -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)
@@ -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().<DomainBase>getParent());
domains.get(loadedToken.getRedemptionHistoryEntry().get().<DomainBase>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<Key<DomainBase>> domainKeys =
tokens.stream()
.map(AllocationToken::getRedemptionHistoryEntry)
.filter(Objects::nonNull)
.filter(Optional::isPresent)
.map(Optional::get)
.map(Key::<DomainBase>getParent)
.collect(toImmutableList());
ImmutableMap.Builder<Key<DomainBase>, DomainBase> domainsBuilder = new ImmutableMap.Builder<>();
@@ -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();
}