1
0
mirror of https://github.com/google/nomulus synced 2026-07-19 06:22:33 +00:00

Implement Expiry Access Period flows and billing (#3131)

This commit implements the second stage (Java ORM mappings and EPP flow
enforcement) of the Expiry Access Period (XAP) launch and opt-in mechanism,
completing the Two-PR deployment split mandated by db/README.md after the
Stage 1 database migrations (PR #3134) are live.

During XAP, a TLD charges a fee for domain registration during a timed period
after deletion. To prevent accidental charges, registrars must explicitly
opt in to participate in XAP registrations.

Specifically, this commit:
- Adds expiryAccessPeriodTransitions (a TimedTransitionProperty of
  ExpiryAccessPeriodMode) to Tld.java and ExpiryAccessPeriodModeTransitionUserType
  for mapping XAP mode schedules to PostgreSQL hstore columns.
- Adds expiryAccessPeriodEnabled to Registrar.java with getter, builder
  setter, and JSON map serialization, and regenerates db-schema.sql.generated.
- Enforces registrar opt-in in DomainCheckFlow: when an unallocated domain is
  in XAP and the querying registrar has not opted in, domain:check returns
  avail="0" with reason "Reserved".
- Enforces registrar opt-in in DomainCreateFlow: when attempting to register
  a domain in XAP without registrar opt-in, throws DomainReservedException
  (EPP error 2304 "Object status prohibits operation").
- Enforces explicit fee acknowledgment during XAP domain creation when the XAP
  fee is non-zero, throwing FeesRequiredDuringExpiryAccessPeriodException in
  DomainFlowUtils if omitted.
- Creates a one-time BillingEvent with Reason.FEE_EXPIRY_ACCESS when a domain
  is created during XAP with a non-zero fee, and adds getXapCost() to
  FeesAndCredits.
- Updates all test TLD YAML configurations and adds comprehensive unit and
  integration tests across DomainCheckFlowTest, DomainCreateFlowTest,
  DomainPricingLogicTest, and RegistrarTest.

TAG=agy
CONV=f2488c74-8b4a-43f1-9c22-d1dddbdbb4e0
BUG=http://b/437398822
This commit is contained in:
Ben McIlwain
2026-07-15 17:25:40 -04:00
committed by GitHub
parent 542084eb70
commit 192a7e6c4e
44 changed files with 1852 additions and 56 deletions
@@ -16,6 +16,7 @@ package google.registry.config;
import static com.google.common.base.Suppliers.memoize;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.collect.ImmutableMap.toImmutableMap;
import static com.google.common.collect.ImmutableSortedMap.toImmutableSortedMap;
import static google.registry.config.ConfigUtils.makeUrl;
import static google.registry.util.DateTimeUtils.START_INSTANT;
@@ -49,6 +50,7 @@ import jakarta.mail.internet.AddressException;
import jakarta.mail.internet.InternetAddress;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.math.BigDecimal;
import java.net.URI;
import java.net.URL;
import java.time.DayOfWeek;
@@ -59,6 +61,7 @@ import java.util.Optional;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import javax.annotation.Nullable;
import org.joda.money.CurrencyUnit;
/**
* Central clearing-house for all configuration.
@@ -1110,6 +1113,40 @@ public final class RegistryConfig {
return config.registryPolicy.registryAdminClientId;
}
/** Returns the total length of the Expiry Access Period. */
@Provides
@Config("domainExpiryAccessPeriodTotalLength")
public static Duration provideDomainExpiryAccessPeriodTotalLength(
RegistryConfigSettings config) {
return Duration.ofSeconds(config.registryPolicy.domainExpiryAccessPeriod.totalLengthSeconds);
}
/** Returns the length of each tier of the Expiry Access Period. */
@Provides
@Config("domainExpiryAccessPeriodTierLength")
public static Duration provideDomainExpiryAccessPeriodTierLength(
RegistryConfigSettings config) {
return Duration.ofSeconds(config.registryPolicy.domainExpiryAccessPeriod.tierLengthSeconds);
}
/** Returns a map of the fee for the first tier of the Expiry Access Period, by currency. */
@Provides
@Config("domainExpiryAccessPeriodInitialFee")
public static ImmutableMap<CurrencyUnit, BigDecimal> provideDomainExpiryAccessPeriodInitialFee(
RegistryConfigSettings config) {
return config.registryPolicy.domainExpiryAccessPeriod.initialFee.entrySet().stream()
.collect(toImmutableMap(entry -> CurrencyUnit.of(entry.getKey()), Entry::getValue));
}
/** Returns a map of the fee for the last tier of the Expiry Access Period, by currency. */
@Provides
@Config("domainExpiryAccessPeriodFinalFee")
public static ImmutableMap<CurrencyUnit, BigDecimal> provideDomainExpiryAccessPeriodFinalFee(
RegistryConfigSettings config) {
return config.registryPolicy.domainExpiryAccessPeriod.finalFee.entrySet().stream()
.collect(toImmutableMap(entry -> CurrencyUnit.of(entry.getKey()), Entry::getValue));
}
@Singleton
@Provides
static RegistryConfigSettings provideRegistryConfigSettings() {
@@ -14,6 +14,7 @@
package google.registry.config;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -94,6 +95,7 @@ public class RegistryConfigSettings {
public String tmchCrlUrl;
public String tmchMarksDbUrl;
public String registryAdminClientId;
public DomainExpiryAccessPeriod domainExpiryAccessPeriod;
public String premiumTermsExportDisclaimer;
public String reservedTermsExportDisclaimer;
public String rdapTos;
@@ -106,6 +108,13 @@ public class RegistryConfigSettings {
public Set<String> noPollMessageOnDeletionRegistrarIds;
}
public static class DomainExpiryAccessPeriod {
public long totalLengthSeconds;
public long tierLengthSeconds;
public Map<String, BigDecimal> initialFee;
public Map<String, BigDecimal> finalFee;
}
/** Configuration for Hibernate. */
public static class Hibernate {
public boolean allowNestedTransactions;
@@ -88,6 +88,28 @@ registryPolicy:
# registrar
registryAdminClientId: TheRegistrar
# Configuration for the Expiry Access Period (XAP) that occurs immediately
# following completion of a domain's deletion. This needs to be enabled on a
# per-TLD basis as well.
domainExpiryAccessPeriod:
# The length of the total expiry access period; defaults to 10 days.
totalLengthSeconds: 864000
# The length of each tier during the expiry access period; defaults to 1
# hour. The XAP fee remains constant for the duration of each tier. The
# total length must be evenly divisible by the tier length, i.e. there
# should be a whole number of tiers all the same length.
tierLengthSeconds: 3600
# The initial fee for the first tier when the expiry access period starts,
# specified for each currency this registry platform uses.
initialFee:
USD: 100000
JPY: 10000000
# The final fee for the last tier when the expiry access period ends,
# specified for each currency this registry platform uses.
finalFee:
USD: 10
JPY: 1000
# Disclaimer at the top of the exported premium terms list.
premiumTermsExportDisclaimer: |
This list contains domains for the TLD offered at a premium price. This
@@ -46,6 +46,7 @@ import google.registry.model.transfer.TransferStatus;
import google.registry.persistence.VKey;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
@@ -100,8 +101,30 @@ public final class ResourceFlowUtils {
public static <R extends EppResource> void verifyResourceDoesNotExist(
Class<R> clazz, String targetId, Instant now, String registrarId) throws EppException {
Optional<R> resource = ForeignKeyUtils.loadResource(clazz, targetId, now);
verifyResourceDoesNotExist(clazz, targetId, now, Duration.ZERO, registrarId);
}
/**
* Verifies that a resource with the specified type and id did not exist at the specified time.
*
* <p>This will throw an exception if the resource exists with a deletionTime greater than the
* specified time (i.e. a registrar is attempting to create a duplicate domain, host, or contact).
* If the resource had existed within the specified lookback window, but is not active now, i.e.
* it is recently deleted, then don't throw an exception, but do return the recently deleted
* resource for further processing (this is used by the Expiry Access Period).
*
* <p>If there is no need to specially handle the situation of recently deleted resources, then
* simply pass a lookback duration of {@link Duration#ZERO}.
*/
public static <R extends EppResource> Optional<R> verifyResourceDoesNotExist(
Class<R> clazz, String targetId, Instant now, Duration lookback, String registrarId)
throws EppException {
Instant startOfWindow = now.minus(lookback);
Optional<R> resource = ForeignKeyUtils.loadResource(clazz, targetId, startOfWindow);
if (resource.isPresent()) {
if (resource.get().getDeletionTime().isBefore(now)) {
return resource;
}
// These are similar exceptions, but we can track them internally as log-based metrics.
if (Objects.equals(registrarId, resource.get().getPersistedCurrentSponsorRegistrarId())) {
throw new ResourceAlreadyExistsForThisClientException(targetId);
@@ -109,6 +132,7 @@ public final class ResourceFlowUtils {
throw new ResourceCreateContentionException(targetId);
}
}
return Optional.empty();
}
/** Check that the given AuthInfo is present for a resource being transferred. */
@@ -25,6 +25,7 @@ import static google.registry.flows.domain.DomainFlowUtils.checkHasBillingAccoun
import static google.registry.flows.domain.DomainFlowUtils.getReservationTypes;
import static google.registry.flows.domain.DomainFlowUtils.handleFeeRequest;
import static google.registry.flows.domain.DomainFlowUtils.isAnchorTenant;
import static google.registry.flows.domain.DomainFlowUtils.isDomainEligibleForXap;
import static google.registry.flows.domain.DomainFlowUtils.isRegisterBsaCreate;
import static google.registry.flows.domain.DomainFlowUtils.isReserved;
import static google.registry.flows.domain.DomainFlowUtils.isValidReservedCreate;
@@ -75,6 +76,7 @@ import google.registry.model.eppoutput.CheckData.DomainCheck;
import google.registry.model.eppoutput.CheckData.DomainCheckData;
import google.registry.model.eppoutput.EppResponse;
import google.registry.model.eppoutput.EppResponse.ResponseExtension;
import google.registry.model.registrar.Registrar;
import google.registry.model.reporting.IcannReportingTypes.ActivityReportField;
import google.registry.model.tld.Tld;
import google.registry.model.tld.Tld.TldState;
@@ -82,6 +84,7 @@ import google.registry.model.tld.label.ReservationType;
import google.registry.persistence.VKey;
import google.registry.util.Clock;
import jakarta.inject.Inject;
import java.time.Duration;
import java.time.Instant;
import java.util.Collection;
import java.util.HashSet;
@@ -138,6 +141,10 @@ public final class DomainCheckFlow implements TransactionalFlow {
@Config("maxChecks")
int maxChecks;
@Inject
@Config("domainExpiryAccessPeriodTotalLength")
Duration domainExpiryAccessPeriodTotalLength;
@Inject @Superuser boolean isSuperuser;
@Inject Clock clock;
@Inject EppResponse.Builder responseBuilder;
@@ -249,7 +256,14 @@ public final class DomainCheckFlow implements TransactionalFlow {
return Optional.of(e.getMessage());
}
return getMessageForCheckWithToken(
idn, existingDomains, bsaBlockedDomainNames, tldStates, token);
idn, existingDomains, bsaBlockedDomainNames, tldStates, token, now);
}
private static Optional<Domain> loadDomainIfInXap(
String domainName, Instant now, Duration domainExpiryAccessPeriodTotalLength) {
return ForeignKeyUtils.loadResource(
Domain.class, domainName, now.minus(domainExpiryAccessPeriodTotalLength))
.filter(domain -> isDomainEligibleForXap(domain, Tld.get(domain.getTld()), now));
}
private Optional<String> getMessageForCheckWithToken(
@@ -257,10 +271,18 @@ public final class DomainCheckFlow implements TransactionalFlow {
ImmutableMap<String, VKey<Domain>> existingDomains,
ImmutableSet<InternetDomainName> bsaBlockedDomains,
ImmutableMap<String, TldState> tldStates,
Optional<AllocationToken> allocationToken) {
Optional<AllocationToken> allocationToken,
Instant now) {
if (existingDomains.containsKey(domainName.toString())) {
return Optional.of("In use");
}
Tld tld = Tld.get(domainName.parent().toString());
if (tld.getExpiryAccessPeriodModeAt(now) == Tld.ExpiryAccessPeriodMode.ENABLED
&& !Registrar.loadByRegistrarIdCached(registrarId).get().getExpiryAccessPeriodEnabled()
&& loadDomainIfInXap(domainName.toString(), now, domainExpiryAccessPeriodTotalLength)
.isPresent()) {
return Optional.of("Reserved");
}
TldState tldState = tldStates.get(domainName.parent().toString());
if (isReserved(domainName, START_DATE_SUNRISE.equals(tldState))) {
if (!isValidReservedCreate(domainName, allocationToken)
@@ -339,16 +361,27 @@ public final class DomainCheckFlow implements TransactionalFlow {
.build());
continue;
}
Optional<Domain> domainForFee = domain;
boolean isAvailable = availableDomains.contains(domainName);
if (isAvailable
&& feeCheckItem.getCommandName().equals(FeeQueryCommandExtensionItem.CommandName.CREATE)
&& tld.getExpiryAccessPeriodModeAt(now) == Tld.ExpiryAccessPeriodMode.ENABLED) {
Optional<Domain> recentlyDeletedDomain =
loadDomainIfInXap(domainName, now, domainExpiryAccessPeriodTotalLength);
if (recentlyDeletedDomain.isPresent()) {
domainForFee = recentlyDeletedDomain;
}
}
handleFeeRequest(
feeCheckItem,
builder,
domainNames.get(domainName),
domain,
domainForFee,
feeCheck.getCurrency(),
now,
pricingLogic,
token,
availableDomains.contains(domainName),
isAvailable,
recurrences.getOrDefault(domainName, null));
// In the case of a registrar that is running a tiered pricing promotion, we issue two
// responses for the CREATE fee check command: one (the default response) with the
@@ -18,6 +18,7 @@ import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static google.registry.dns.DnsUtils.requestDomainDnsRefresh;
import static google.registry.flows.FlowUtils.persistEntityChanges;
import static google.registry.flows.FlowUtils.validateRegistrarIsLoggedIn;
import static google.registry.flows.ResourceFlowUtils.verifyResourceDoesNotExist;
import static google.registry.flows.domain.DomainFlowUtils.COLLISION_MESSAGE;
import static google.registry.flows.domain.DomainFlowUtils.checkAllowedAccessToTld;
import static google.registry.flows.domain.DomainFlowUtils.checkHasBillingAccount;
@@ -25,6 +26,7 @@ import static google.registry.flows.domain.DomainFlowUtils.cloneAndLinkReference
import static google.registry.flows.domain.DomainFlowUtils.createFeeCreateResponse;
import static google.registry.flows.domain.DomainFlowUtils.getReservationTypes;
import static google.registry.flows.domain.DomainFlowUtils.isAnchorTenant;
import static google.registry.flows.domain.DomainFlowUtils.isDomainEligibleForXap;
import static google.registry.flows.domain.DomainFlowUtils.isReserved;
import static google.registry.flows.domain.DomainFlowUtils.isValidReservedCreate;
import static google.registry.flows.domain.DomainFlowUtils.validateCreateCommandContactsAndNameservers;
@@ -58,6 +60,7 @@ import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.net.InternetDomainName;
import google.registry.config.RegistryConfig;
import google.registry.config.RegistryConfig.Config;
import google.registry.flows.EppException;
import google.registry.flows.EppException.CommandUseErrorException;
import google.registry.flows.EppException.ParameterValuePolicyErrorException;
@@ -71,6 +74,7 @@ import google.registry.flows.custom.DomainCreateFlowCustomLogic;
import google.registry.flows.custom.DomainCreateFlowCustomLogic.BeforeResponseParameters;
import google.registry.flows.custom.DomainCreateFlowCustomLogic.BeforeResponseReturnData;
import google.registry.flows.custom.EntityChanges;
import google.registry.flows.domain.DomainFlowUtils.DomainReservedException;
import google.registry.flows.domain.DomainFlowUtils.RegistrantProhibitedException;
import google.registry.flows.domain.token.AllocationTokenFlowUtils;
import google.registry.flows.exceptions.ContactsProhibitedException;
@@ -107,6 +111,7 @@ import google.registry.model.eppoutput.EppResponse;
import google.registry.model.poll.PendingActionNotificationResponse.DomainPendingActionNotificationResponse;
import google.registry.model.poll.PollMessage;
import google.registry.model.poll.PollMessage.Autorenew;
import google.registry.model.registrar.Registrar;
import google.registry.model.reporting.DomainTransactionRecord;
import google.registry.model.reporting.DomainTransactionRecord.TransactionReportField;
import google.registry.model.reporting.HistoryEntry;
@@ -123,6 +128,7 @@ import jakarta.inject.Inject;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import org.joda.money.Money;
/**
* An EPP flow that creates a new domain resource.
@@ -219,6 +225,10 @@ public final class DomainCreateFlow implements MutatingFlow {
@Inject DomainPricingLogic pricingLogic;
@Inject DomainDeletionTimeCache domainDeletionTimeCache;
@Inject
@Config("domainExpiryAccessPeriodTotalLength")
Duration domainExpiryAccessPeriodTotalLength;
@Inject
DomainCreateFlow() {}
@@ -245,6 +255,13 @@ public final class DomainCreateFlow implements MutatingFlow {
InternetDomainName domainName = validateDomainName(command.getDomainName());
String domainLabel = domainName.parts().getFirst();
Tld tld = Tld.get(domainName.parent().toString());
Duration lookback =
tld.getExpiryAccessPeriodModeAt(now) == Tld.ExpiryAccessPeriodMode.ENABLED
? domainExpiryAccessPeriodTotalLength
: Duration.ZERO;
Optional<Domain> recentlyDeletedDomain =
verifyResourceDoesNotExist(Domain.class, targetId, now, lookback, registrarId)
.filter(domain -> isDomainEligibleForXap(domain, tld, now));
validateCreateCommandContactsAndNameservers(command, tld, domainName);
TldState tldState = tld.getTldState(now);
Optional<LaunchCreateExtension> launchCreate =
@@ -280,6 +297,10 @@ public final class DomainCreateFlow implements MutatingFlow {
if (!isSuperuser) {
checkAllowedAccessToTld(registrarId, tld.getTldStr());
checkHasBillingAccount(registrarId, tld.getTldStr());
if (recentlyDeletedDomain.isPresent()
&& !Registrar.loadByRegistrarIdCached(registrarId).get().getExpiryAccessPeriodEnabled()) {
throw new DomainReservedException(domainName.toString());
}
boolean isValidReservedCreate = isValidReservedCreate(domainName, allocationToken);
ClaimsList claimsList = ClaimsListDao.get();
verifyIsGaOrSpecialCase(
@@ -327,7 +348,14 @@ public final class DomainCreateFlow implements MutatingFlow {
eppInput.getSingleExtension(FeeCreateCommandExtension.class);
FeesAndCredits feesAndCredits =
pricingLogic.getCreatePrice(
tld, targetId, now, years, isAnchorTenant, isSunriseCreate, allocationToken);
tld,
targetId,
now,
recentlyDeletedDomain,
years,
isAnchorTenant,
isSunriseCreate,
allocationToken);
validateFeeChallenge(feeCreate, feesAndCredits, defaultTokenUsed);
Optional<SecDnsCreateExtension> secDnsCreate =
validateSecDnsExtension(eppInput.getSingleExtension(SecDnsCreateExtension.class));
@@ -358,7 +386,15 @@ public final class DomainCreateFlow implements MutatingFlow {
entitiesToInsert.add(createBillingEvent, autorenewBillingEvent, autorenewPollMessage);
// Bill for EAP cost, if any.
if (!feesAndCredits.getEapCost().isZero()) {
entitiesToInsert.add(createEapBillingEvent(feesAndCredits, createBillingEvent));
entitiesToInsert.add(
createAdditionalBillingEvent(
Reason.FEE_EARLY_ACCESS, feesAndCredits.getEapCost(), createBillingEvent));
}
// Bill for XAP cost, if any.
if (!feesAndCredits.getXapCost().isZero()) {
entitiesToInsert.add(
createAdditionalBillingEvent(
Reason.FEE_EXPIRY_ACCESS, feesAndCredits.getXapCost(), createBillingEvent));
}
ImmutableSet<ReservationType> reservationTypes = getReservationTypes(domainName);
@@ -434,7 +470,14 @@ public final class DomainCreateFlow implements MutatingFlow {
FeesAndCredits responseFeesAndCredits =
shouldShowDefaultPrice
? pricingLogic.getCreatePrice(
tld, targetId, now, years, isAnchorTenant, isSunriseCreate, Optional.empty())
tld,
targetId,
now,
recentlyDeletedDomain,
years,
isAnchorTenant,
isSunriseCreate,
Optional.empty())
: feesAndCredits;
BeforeResponseReturnData responseData =
@@ -656,14 +699,14 @@ public final class DomainCreateFlow implements MutatingFlow {
}
}
private static BillingEvent createEapBillingEvent(
FeesAndCredits feesAndCredits, BillingEvent createBillingEvent) {
private static BillingEvent createAdditionalBillingEvent(
Reason reason, Money cost, BillingEvent createBillingEvent) {
return new BillingEvent.Builder()
.setReason(Reason.FEE_EARLY_ACCESS)
.setReason(reason)
.setTargetId(createBillingEvent.getTargetId())
.setRegistrarId(createBillingEvent.getRegistrarId())
.setPeriodYears(1)
.setCost(feesAndCredits.getEapCost())
.setCost(cost)
.setEventTime(createBillingEvent.getEventTime())
.setBillingTime(createBillingEvent.getBillingTime())
.setFlags(createBillingEvent.getFlags())
@@ -642,6 +642,7 @@ public class DomainFlowUtils {
tld,
domainNameString,
now,
domain,
years,
isAnchorTenant(domainName, allocationToken, Optional.empty()),
isSunrise,
@@ -765,6 +766,9 @@ public class DomainFlowUtils {
if (!feesAndCredits.getEapCost().isZero()) {
throw new FeesRequiredDuringEarlyAccessProgramException(feesAndCredits.getEapCost());
}
if (!feesAndCredits.getXapCost().isZero()) {
throw new FeesRequiredDuringExpiryAccessPeriodException(feesAndCredits.getXapCost());
}
if (feesAndCredits.getTotalCost().isZero() || !feesAndCredits.isFeeExtensionRequired()) {
return;
}
@@ -1168,6 +1172,32 @@ public class DomainFlowUtils {
.getResultList();
}
/**
* Returns true if the domain was deleted during its Add Grace Period (AGP).
*
* <p>Per policy, domains deleted during AGP are not subject to Expiry Access Period (XAP) fees or
* reservation restrictions upon subsequent re-registration.
*/
public static boolean wasDeletedDuringAddGracePeriod(Domain domain, Tld tld) {
if (domain.getCreationTime() == null || domain.getDeletionTime() == null) {
return false;
}
return !domain
.getDeletionTime()
.isAfter(domain.getCreationTime().plus(tld.getAddGracePeriodLength()));
}
/**
* Returns true if the domain was deleted before {@code now} and is eligible for Expiry Access
* Period (XAP) evaluation.
*/
public static boolean isDomainEligibleForXap(Domain domain, Tld tld, Instant now) {
if (domain.getDeletionTime() == null || !domain.getDeletionTime().isBefore(now)) {
return false;
}
return !wasDeletedDuringAddGracePeriod(domain, tld);
}
/** Resource linked to this domain does not exist. */
static class LinkedResourcesDoNotExistException extends ObjectDoesNotExistException {
public LinkedResourcesDoNotExistException(Class<?> type, ImmutableSet<String> resourceIds) {
@@ -1355,6 +1385,18 @@ public class DomainFlowUtils {
}
}
/** Fees must be explicitly acknowledged when creating domains during the Expiry Access Period. */
static class FeesRequiredDuringExpiryAccessPeriodException
extends RequiredParameterMissingException {
public FeesRequiredDuringExpiryAccessPeriodException(Money expectedFee) {
super(
"Fees must be explicitly acknowledged when creating domains "
+ "during the Expiry Access Period. The XAP fee is: "
+ expectedFee);
}
}
/** The 'grace-period', 'applied' and 'refundable' fields are disallowed by server policy. */
static class UnsupportedFeeAttributeException extends UnimplementedOptionException {
UnsupportedFeeAttributeException() {
@@ -15,13 +15,18 @@
package google.registry.flows.domain;
import static com.google.common.base.Preconditions.checkArgument;
import static google.registry.flows.domain.DomainFlowUtils.isDomainEligibleForXap;
import static google.registry.flows.domain.DomainFlowUtils.zeroInCurrency;
import static google.registry.flows.domain.token.AllocationTokenFlowUtils.discountTokenInvalidForPremiumName;
import static google.registry.pricing.PricingEngineProxy.getPricesForDomainName;
import static google.registry.util.PreconditionsUtils.checkArgumentPresent;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Range;
import com.google.common.flogger.FluentLogger;
import com.google.common.net.InternetDomainName;
import google.registry.config.RegistryConfig;
import google.registry.config.RegistryConfig.Config;
import google.registry.flows.EppException;
import google.registry.flows.custom.DomainPricingCustomLogic;
import google.registry.flows.custom.DomainPricingCustomLogic.CreatePriceParameters;
@@ -31,6 +36,7 @@ import google.registry.flows.custom.DomainPricingCustomLogic.TransferPriceParame
import google.registry.flows.custom.DomainPricingCustomLogic.UpdatePriceParameters;
import google.registry.model.billing.BillingBase.RenewalPriceBehavior;
import google.registry.model.billing.BillingRecurrence;
import google.registry.model.domain.Domain;
import google.registry.model.domain.fee.BaseFee;
import google.registry.model.domain.fee.BaseFee.FeeType;
import google.registry.model.domain.fee.Fee;
@@ -40,7 +46,9 @@ import google.registry.model.domain.token.AllocationToken.TokenBehavior;
import google.registry.model.pricing.PremiumPricingEngine.DomainPrices;
import google.registry.model.tld.Tld;
import jakarta.inject.Inject;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import javax.annotation.Nullable;
@@ -54,11 +62,28 @@ import org.joda.money.Money;
*/
public final class DomainPricingLogic {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private final DomainPricingCustomLogic customLogic;
private final Duration expiryAccessPeriodTotalLength;
private final Duration expiryAccessPeriodTierLength;
private final ImmutableMap<CurrencyUnit, BigDecimal> expiryAccessPeriodInitialFee;
private final ImmutableMap<CurrencyUnit, BigDecimal> expiryAccessPeriodFinalFee;
@Inject
public DomainPricingLogic(DomainPricingCustomLogic customLogic) {
public DomainPricingLogic(
DomainPricingCustomLogic customLogic,
@Config("domainExpiryAccessPeriodTotalLength") Duration expiryAccessPeriodTotalLength,
@Config("domainExpiryAccessPeriodTierLength") Duration expiryAccessPeriodTierLength,
@Config("domainExpiryAccessPeriodInitialFee")
ImmutableMap<CurrencyUnit, BigDecimal> expiryAccessPeriodInitialFee,
@Config("domainExpiryAccessPeriodFinalFee")
ImmutableMap<CurrencyUnit, BigDecimal> expiryAccessPeriodFinalFee) {
this.customLogic = customLogic;
this.expiryAccessPeriodTotalLength = expiryAccessPeriodTotalLength;
this.expiryAccessPeriodTierLength = expiryAccessPeriodTierLength;
this.expiryAccessPeriodInitialFee = expiryAccessPeriodInitialFee;
this.expiryAccessPeriodFinalFee = expiryAccessPeriodFinalFee;
}
/**
@@ -71,35 +96,23 @@ public final class DomainPricingLogic {
Tld tld,
String domainName,
Instant dateTime,
Optional<Domain> recentlyDeletedDomain,
int years,
boolean isAnchorTenant,
boolean isSunriseCreate,
Optional<AllocationToken> allocationToken)
throws EppException {
CurrencyUnit currency = tld.getCurrency();
BaseFee createFee;
// Domain create cost is always zero for anchor tenants
if (isAnchorTenant) {
createFee = Fee.create(zeroInCurrency(currency), FeeType.CREATE, false);
} else {
DomainPrices domainPrices = getPricesForDomainName(domainName, dateTime);
if (allocationToken.isPresent()) {
// Handle any special NONPREMIUM / SPECIFIED cases configured in the token
domainPrices =
applyTokenToDomainPrices(domainPrices, tld, dateTime, years, allocationToken.get());
}
Money domainCreateCost =
getDomainCreateCostWithDiscount(domainPrices, years, allocationToken, tld);
// Apply a sunrise discount if configured and applicable
if (isSunriseCreate) {
domainCreateCost =
domainCreateCost.multipliedBy(
1.0d - RegistryConfig.getSunriseDomainCreateDiscount(), RoundingMode.HALF_EVEN);
}
createFee =
Fee.create(domainCreateCost.getAmount(), FeeType.CREATE, domainPrices.isPremium());
}
Fee createFee =
computeCreateFee(
tld,
domainName,
dateTime,
years,
isAnchorTenant,
isSunriseCreate,
allocationToken,
currency);
// Create fees for the cost and the EAP fee, if any.
Fee eapFee = tld.getEapFeeFor(dateTime);
@@ -109,6 +122,7 @@ public final class DomainPricingLogic {
if (!isAnchorTenant && !eapFee.hasZeroCost()) {
feesBuilder.addFeeOrCredit(eapFee);
}
maybeAddXapFee(tld, dateTime, recentlyDeletedDomain, currency, feesBuilder);
// Apply custom logic to the create fee, if any.
return customLogic.customizeCreatePrice(
@@ -121,6 +135,113 @@ public final class DomainPricingLogic {
.build());
}
private Fee computeCreateFee(
Tld tld,
String domainName,
Instant dateTime,
int years,
boolean isAnchorTenant,
boolean isSunriseCreate,
Optional<AllocationToken> allocationToken,
CurrencyUnit currency)
throws EppException {
// Domain create cost is always zero for anchor tenants
if (isAnchorTenant) {
return Fee.create(zeroInCurrency(currency), FeeType.CREATE, false);
}
DomainPrices domainPrices = getPricesForDomainName(domainName, dateTime);
if (allocationToken.isPresent()) {
// Handle any special NONPREMIUM / SPECIFIED cases configured in the token
domainPrices =
applyTokenToDomainPrices(domainPrices, tld, dateTime, years, allocationToken.get());
}
Money domainCreateCost =
getDomainCreateCostWithDiscount(domainPrices, years, allocationToken, tld);
// Apply a sunrise discount if configured and applicable
if (isSunriseCreate) {
domainCreateCost =
domainCreateCost.multipliedBy(
1.0d - RegistryConfig.getSunriseDomainCreateDiscount(), RoundingMode.HALF_EVEN);
}
return Fee.create(domainCreateCost.getAmount(), FeeType.CREATE, domainPrices.isPremium());
}
private void maybeAddXapFee(
Tld tld,
Instant dateTime,
Optional<Domain> recentlyDeletedDomain,
CurrencyUnit currency,
FeesAndCredits.Builder feesBuilder) {
if (tld.getExpiryAccessPeriodModeAt(dateTime) == Tld.ExpiryAccessPeriodMode.ENABLED
&& recentlyDeletedDomain.isPresent()
&& isDomainEligibleForXap(recentlyDeletedDomain.get(), tld, dateTime)) {
Optional<Fee> xapFee =
getXapFeeFor(dateTime, recentlyDeletedDomain.get().getDeletionTime(), currency);
xapFee.ifPresent(
fee -> {
feesBuilder.addFeeOrCredit(fee);
if (!fee.hasZeroCost()) {
feesBuilder.setFeeExtensionRequired(true);
}
});
}
}
/**
* Calculates and returns the Expiry Access Fee for a recently deleted domain at the given time.
*/
Optional<Fee> getXapFeeFor(Instant dateTime, Instant deletionTime, CurrencyUnit currency) {
Duration elapsedTimeInXap = Duration.between(deletionTime, dateTime);
if (!expiryAccessPeriodInitialFee.containsKey(currency)
|| !expiryAccessPeriodFinalFee.containsKey(currency)) {
// If the XAP schedule hasn't been configured in YAML for the currency this TLD uses, log the
// error and then short-circuit return (to allow the EPP flow to continue normally).
logger.atSevere().log(
"Expiry Access Period configuration is lacking initial or final fees for currency %s.",
currency);
return Optional.empty();
}
// Determine which tier the current time falls into (0-indexed).
long tier = elapsedTimeInXap.toMillis() / expiryAccessPeriodTierLength.toMillis();
long numTiers =
expiryAccessPeriodTotalLength.toMillis() / expiryAccessPeriodTierLength.toMillis();
if (tier < 0 || tier >= numTiers) {
return Optional.empty();
}
// Calculate the parameters for the geometric sequence: XAP fee = initialFee * ratio ^ tier.
BigDecimal xapFee;
if (expiryAccessPeriodInitialFee.get(currency).signum() == 0 || numTiers <= 1 || tier == 0) {
xapFee =
expiryAccessPeriodInitialFee
.get(currency)
.setScale(currency.getDecimalPlaces(), RoundingMode.HALF_EVEN);
} else {
double base =
expiryAccessPeriodFinalFee.get(currency).doubleValue()
/ expiryAccessPeriodInitialFee.get(currency).doubleValue();
double exponent = 1.0 / (numTiers - 1.0);
BigDecimal ratio = BigDecimal.valueOf(Math.pow(base, exponent));
BigDecimal fee = expiryAccessPeriodInitialFee.get(currency).multiply(ratio.pow((int) tier));
xapFee = fee.setScale(currency.getDecimalPlaces(), RoundingMode.HALF_EVEN);
}
Range<Instant> validPeriod =
Range.closedOpen(
deletionTime.plus(expiryAccessPeriodTierLength.multipliedBy(tier)),
deletionTime.plus(expiryAccessPeriodTierLength.multipliedBy(tier + 1)));
return Optional.of(
Fee.create(
xapFee,
FeeType.XAP,
// An XAP fee does not count as premium -- it's a separate one-time fee, independent of
// which the domain is separately considered standard vs premium.
false,
validPeriod,
validPeriod.upperEndpoint()));
}
/** Returns a new renewal cost for the pricer. */
public FeesAndCredits getRenewPrice(
Tld tld,
@@ -77,6 +77,11 @@ public class FeesAndCredits extends ImmutableObject implements Buildable {
return getTotalCostForType(FeeType.EAP);
}
/** Returns the XAP cost for the event. */
public Money getXapCost() {
return getTotalCostForType(FeeType.XAP);
}
/** Returns the renew cost for the event. */
public Money getRenewCost() {
return getTotalCostForType(FeeType.RENEW);
@@ -242,7 +242,14 @@ public class AllocationTokenFlowUtils {
case CREATE ->
pricingLogic
.getCreatePrice(
tld, domainName, now, yearsForAction, false, false, Optional.of(token))
tld,
domainName,
now,
Optional.empty(),
yearsForAction,
false,
false,
Optional.of(token))
.getTotalCost()
.getAmount();
case RENEW ->
@@ -21,6 +21,7 @@ import static google.registry.util.DateTimeUtils.parseInstant;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.KeyDeserializer;
@@ -38,6 +39,7 @@ import com.google.common.collect.ImmutableSortedSet;
import google.registry.model.common.FeatureFlag.FeatureStatus;
import google.registry.model.common.TimedTransitionProperty;
import google.registry.model.domain.token.AllocationToken;
import google.registry.model.tld.Tld.ExpiryAccessPeriodMode;
import google.registry.model.tld.Tld.TldState;
import google.registry.persistence.VKey;
import java.io.IOException;
@@ -336,7 +338,8 @@ public class EntityYamlUtils {
@Override
public TimedTransitionProperty<TldState> deserialize(
JsonParser jp, DeserializationContext context) throws IOException {
SortedMap<String, String> valueMap = jp.readValueAs(SortedMap.class);
SortedMap<String, String> valueMap =
jp.readValueAs(new TypeReference<SortedMap<String, String>>() {});
return TimedTransitionProperty.fromValueMap(
valueMap.keySet().stream()
.collect(
@@ -347,6 +350,37 @@ public class EntityYamlUtils {
}
}
/**
* A custom JSON deserializer for a {@link TimedTransitionProperty} of {@link
* ExpiryAccessPeriodMode}.
*/
public static class TimedTransitionPropertyExpiryAccessPeriodModeDeserializer
extends StdDeserializer<TimedTransitionProperty<ExpiryAccessPeriodMode>> {
public TimedTransitionPropertyExpiryAccessPeriodModeDeserializer() {
this(null);
}
public TimedTransitionPropertyExpiryAccessPeriodModeDeserializer(
Class<TimedTransitionProperty<ExpiryAccessPeriodMode>> t) {
super(t);
}
@Override
public TimedTransitionProperty<ExpiryAccessPeriodMode> deserialize(
JsonParser jp, DeserializationContext context) throws IOException {
SortedMap<String, String> valueMap =
jp.readValueAs(new TypeReference<SortedMap<String, String>>() {});
return TimedTransitionProperty.fromValueMap(
valueMap.keySet().stream()
.collect(
toImmutableSortedMap(
natural(),
key -> parseInstant(key),
key -> ExpiryAccessPeriodMode.valueOf(valueMap.get(key)))));
}
}
/** A custom JSON deserializer for a {@link TimedTransitionProperty} of {@link Money}. */
public static class TimedTransitionPropertyMoneyDeserializer
extends StdDeserializer<TimedTransitionProperty<Money>> {
@@ -362,7 +396,8 @@ public class EntityYamlUtils {
@Override
public TimedTransitionProperty<Money> deserialize(JsonParser jp, DeserializationContext context)
throws IOException {
SortedMap<String, LinkedHashMap<String, Object>> valueMap = jp.readValueAs(SortedMap.class);
SortedMap<String, LinkedHashMap<String, Object>> valueMap =
jp.readValueAs(new TypeReference<SortedMap<String, LinkedHashMap<String, Object>>>() {});
return TimedTransitionProperty.fromValueMap(
valueMap.keySet().stream()
.collect(
@@ -392,7 +427,8 @@ public class EntityYamlUtils {
@Override
public TimedTransitionProperty<FeatureStatus> deserialize(
JsonParser jp, DeserializationContext context) throws IOException {
SortedMap<String, String> valueMap = jp.readValueAs(SortedMap.class);
SortedMap<String, String> valueMap =
jp.readValueAs(new TypeReference<SortedMap<String, String>>() {});
return TimedTransitionProperty.fromValueMap(
valueMap.keySet().stream()
.collect(
@@ -48,6 +48,7 @@ public abstract class BillingBase extends ImmutableObject
@Deprecated // DO NOT USE THIS REASON. IT REMAINS BECAUSE OF HISTORICAL DATA. SEE b/31676071.
ERROR(false),
FEE_EARLY_ACCESS(true),
FEE_EXPIRY_ACCESS(true),
RENEW(true),
RESTORE(true),
SERVER_STATUS(false),
@@ -200,7 +200,7 @@ public class BillingEvent extends BillingBase {
checkState(
instance.reason.hasPeriodYears() == (instance.periodYears != null),
"Period years must be set if and only if reason is "
+ "CREATE, FEE_EARLY_ACCESS, RENEW, RESTORE or TRANSFER.");
+ "CREATE, FEE_EARLY_ACCESS, FEE_EXPIRY_ACCESS, RENEW, RESTORE or TRANSFER.");
}
checkState(
instance.getFlags().contains(Flag.SYNTHETIC) == (instance.syntheticCreationTime != null),
@@ -51,6 +51,10 @@ public abstract class BaseFee extends ImmutableObject {
public enum FeeType {
CREATE("create"),
EAP("Early Access Period, fee expires: %s", "Early Access Period"),
/**
* A one-time fee for registering a recently dropped domain, during the Expiry Access Period.
*/
XAP("Expiry Access Period, fee expires: %s", "Expiry Access Period"),
RENEW("renew"),
RESTORE("restore"),
/**
@@ -265,6 +265,18 @@ public class Registrar extends UpdateAutoTimestampEntity implements Buildable, J
@Column(nullable = false)
boolean blockPremiumNames;
/**
* Whether this registrar has opted into participating in the Expiry Access Period (XAP).
*
* <p>If this is false, any domain currently in XAP will appear as reserved/unavailable on checks
* and creates.
*
* <p>TODO(mcilwain): Drop the temporary database default for expiry_access_period_enabled in a
* subsequent schema migration once this code is deployed.
*/
@Column(nullable = false)
boolean expiryAccessPeriodEnabled;
// Authentication.
/** X.509 PEM client certificate(s) used to authenticate registrar to EPP service. */
@@ -560,6 +572,10 @@ public class Registrar extends UpdateAutoTimestampEntity implements Buildable, J
return blockPremiumNames;
}
public boolean getExpiryAccessPeriodEnabled() {
return expiryAccessPeriodEnabled;
}
public boolean getContactsRequireSyncing() {
return contactsRequireSyncing;
}
@@ -654,6 +670,7 @@ public class Registrar extends UpdateAutoTimestampEntity implements Buildable, J
.put("whoisServer", getWhoisServer())
.putListOfStrings("rdapBaseUrls", getRdapBaseUrls())
.put("blockPremiumNames", blockPremiumNames)
.put("expiryAccessPeriodEnabled", expiryAccessPeriodEnabled)
.put("url", url)
.put("icannReferralEmail", getIcannReferralEmail())
.put("driveFolderId", driveFolderId)
@@ -918,6 +935,11 @@ public class Registrar extends UpdateAutoTimestampEntity implements Buildable, J
return this;
}
public Builder setExpiryAccessPeriodEnabled(boolean expiryAccessPeriodEnabled) {
getInstance().expiryAccessPeriodEnabled = expiryAccessPeriodEnabled;
return this;
}
public Builder setUrl(String url) {
getInstance().url = url;
return this;
@@ -56,6 +56,7 @@ import google.registry.model.EntityYamlUtils.OptionalDurationSerializer;
import google.registry.model.EntityYamlUtils.OptionalStringSerializer;
import google.registry.model.EntityYamlUtils.SortedEnumSetSerializer;
import google.registry.model.EntityYamlUtils.SortedSetSerializer;
import google.registry.model.EntityYamlUtils.TimedTransitionPropertyExpiryAccessPeriodModeDeserializer;
import google.registry.model.EntityYamlUtils.TimedTransitionPropertyMoneyDeserializer;
import google.registry.model.EntityYamlUtils.TimedTransitionPropertyTldStateDeserializer;
import google.registry.model.EntityYamlUtils.TokenVKeyListDeserializer;
@@ -76,6 +77,7 @@ import google.registry.persistence.EntityCallbacksListener.RecursivePostUpdate;
import google.registry.persistence.VKey;
import google.registry.persistence.converter.AllocationTokenVkeyListUserType;
import google.registry.persistence.converter.BillingCostTransitionUserType;
import google.registry.persistence.converter.ExpiryAccessPeriodModeTransitionUserType;
import google.registry.persistence.converter.TldStateTransitionUserType;
import google.registry.tldconfig.idn.IdnTableEnum;
import google.registry.util.Idn;
@@ -120,6 +122,8 @@ public class Tld extends ImmutableObject implements Buildable, UnsafeSerializabl
public static final boolean DEFAULT_ESCROW_ENABLED = false;
public static final boolean DEFAULT_DNS_PAUSED = false;
public static final ExpiryAccessPeriodMode DEFAULT_EXPIRY_ACCESS_PERIOD_MODE =
ExpiryAccessPeriodMode.DISABLED;
public static final Duration DEFAULT_ADD_GRACE_PERIOD = Duration.ofDays(5);
public static final Duration DEFAULT_AUTO_RENEW_GRACE_PERIOD = Duration.ofDays(45);
public static final Duration DEFAULT_REDEMPTION_GRACE_PERIOD = Duration.ofDays(30);
@@ -197,6 +201,12 @@ public class Tld extends ImmutableObject implements Buildable, UnsafeSerializabl
PDT
}
/** The modes an Expiry Access Period (XAP) can be in at any given point in time. */
public enum ExpiryAccessPeriodMode {
DISABLED,
ENABLED
}
/** Returns the TLD for a given TLD, throwing if none exists. */
public static Tld get(String tld) {
return CACHE.get(tld).orElseThrow(() -> new TldNotFoundException(tld));
@@ -426,6 +436,19 @@ public class Tld extends ImmutableObject implements Buildable, UnsafeSerializabl
@Column(nullable = false)
boolean dnsPaused = DEFAULT_DNS_PAUSED;
/**
* Whether the Expiry Access Period following domain deletes is enabled for this TLD.
*
* <p>TODO(mcilwain): Once this Java code has been fully deployed to production, drop the
* temporary database-level DEFAULT constraint on the "expiry_access_period_transitions" column in
* the "Tld" table in a subsequent schema release.
*/
@Column(nullable = false, columnDefinition = "hstore")
@Type(ExpiryAccessPeriodModeTransitionUserType.class)
@JsonDeserialize(using = TimedTransitionPropertyExpiryAccessPeriodModeDeserializer.class)
TimedTransitionProperty<ExpiryAccessPeriodMode> expiryAccessPeriodTransitions =
TimedTransitionProperty.withInitialValue(DEFAULT_EXPIRY_ACCESS_PERIOD_MODE);
/**
* The length of the add grace period for this TLD.
*
@@ -634,6 +657,14 @@ public class Tld extends ImmutableObject implements Buildable, UnsafeSerializabl
return dnsPaused;
}
public ExpiryAccessPeriodMode getExpiryAccessPeriodModeAt(Instant now) {
return expiryAccessPeriodTransitions.getValueAtTime(now);
}
public ImmutableSortedMap<Instant, ExpiryAccessPeriodMode> getExpiryAccessPeriodTransitions() {
return expiryAccessPeriodTransitions.toValueMap();
}
@Nullable
public String getDriveFolderId() {
return driveFolderId;
@@ -830,6 +861,7 @@ public class Tld extends ImmutableObject implements Buildable, UnsafeSerializabl
createBillingCostTransitions.checkValidity();
renewBillingCostTransitions.checkValidity();
eapFeeSchedule.checkValidity();
expiryAccessPeriodTransitions.checkValidity();
// All costs must be in the expected currency.
checkArgumentNotNull(getCurrency(), "Currency must be set");
Predicate<Money> currencyCheck =
@@ -913,6 +945,13 @@ public class Tld extends ImmutableObject implements Buildable, UnsafeSerializabl
return this;
}
public Builder setExpiryAccessPeriodTransitions(
ImmutableSortedMap<Instant, ExpiryAccessPeriodMode> transitionsMap) {
getInstance().expiryAccessPeriodTransitions =
TimedTransitionProperty.fromValueMap(transitionsMap);
return this;
}
public Builder setDriveFolderId(String driveFolderId) {
getInstance().driveFolderId = driveFolderId;
return this;
@@ -0,0 +1,33 @@
// Copyright 2026 The Nomulus Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package google.registry.persistence.converter;
import google.registry.model.common.TimedTransitionProperty;
import google.registry.model.tld.Tld.ExpiryAccessPeriodMode;
/** Hibernate custom type for {@link TimedTransitionProperty} of {@link ExpiryAccessPeriodMode}. */
public class ExpiryAccessPeriodModeTransitionUserType
extends TimedTransitionBaseUserType<ExpiryAccessPeriodMode> {
@Override
String valueToString(ExpiryAccessPeriodMode value) {
return value.name();
}
@Override
ExpiryAccessPeriodMode stringToValue(String string) {
return ExpiryAccessPeriodMode.valueOf(string);
}
}
@@ -49,6 +49,8 @@ import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableSortedMap;
import com.google.common.collect.Ordering;
import google.registry.config.RegistryConfig;
import google.registry.config.RegistryConfigSettings;
import google.registry.flows.EppException;
import google.registry.flows.FlowUtils.GenericXmlSyntaxErrorException;
import google.registry.flows.FlowUtils.NotLoggedInException;
@@ -95,6 +97,7 @@ import google.registry.model.tld.Tld.TldState;
import google.registry.model.tld.label.ReservedList;
import google.registry.testing.DatabaseHelper;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import org.joda.money.Money;
@@ -2447,6 +2450,283 @@ class DomainCheckFlowTest extends ResourceCheckFlowTestCase<DomainCheckFlow, Dom
runFlowAssertResponse(outputXml);
}
@Test
void testFeeExtension_xapLabel_std_v1() throws Exception {
createTld("tld");
persistResource(
Registrar.loadByRegistrarId("TheRegistrar")
.get()
.asBuilder()
.setExpiryAccessPeriodEnabled(true)
.build());
persistResource(
Tld.get("tld")
.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
RegistryConfigSettings settings = RegistryConfig.CONFIG_SETTINGS.get();
BigDecimal originalInitialFee =
settings.registryPolicy.domainExpiryAccessPeriod.initialFee.get("USD");
BigDecimal originalFinalFee =
settings.registryPolicy.domainExpiryAccessPeriod.finalFee.get("USD");
settings.registryPolicy.domainExpiryAccessPeriod.initialFee =
ImmutableMap.of("USD", new BigDecimal("110.00"));
settings.registryPolicy.domainExpiryAccessPeriod.finalFee =
ImmutableMap.of("USD", new BigDecimal("110.00"));
try {
clock.setTo(Instant.parse("2009-01-06T00:00:00Z"));
Instant deletionTime = Instant.parse("2009-01-01T00:00:00Z");
persistDeletedDomain("example1.tld", deletionTime);
setEppInput("domain_check_fee_stdv1.xml");
runFlowAssertResponse(loadFile("domain_check_fee_xap_response.xml"));
} finally {
settings.registryPolicy.domainExpiryAccessPeriod.initialFee =
ImmutableMap.of("USD", originalInitialFee);
settings.registryPolicy.domainExpiryAccessPeriod.finalFee =
ImmutableMap.of("USD", originalFinalFee);
}
}
@Test
void testCheck_xapLabel_notOptedIn_returnsReserved() throws Exception {
createTld("tld");
persistResource(
Tld.get("tld")
.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
persistResource(
Registrar.loadByRegistrarId("TheRegistrar")
.get()
.asBuilder()
.setExpiryAccessPeriodEnabled(false)
.build());
Instant deletionTime = clock.now().minus(Duration.ofDays(5));
persistDeletedDomain("example1.tld", deletionTime);
setEppInput("domain_check_one_tld.xml");
doCheckTest(
create(false, "example1.tld", "Reserved"),
create(true, "example2.tld", null),
create(true, "example3.tld", null));
}
@Test
void testCheck_recentlyDeletedDomain_afterXapEnds_notOptedIn_returnsAvailable() throws Exception {
// Once the 10-day XAP window expires after deletion (e.g. day 11), checking the domain should
// return available (avail="1") without XAP fees or reservation blocks for non-opted-in
// registrars.
createTld("tld");
persistResource(
Tld.get("tld")
.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
persistResource(
Registrar.loadByRegistrarId("TheRegistrar")
.get()
.asBuilder()
.setExpiryAccessPeriodEnabled(false)
.build());
Instant deletionTime = clock.now().minus(Duration.ofDays(11));
persistDeletedDomain("example1.tld", deletionTime);
setEppInput("domain_check_one_tld.xml");
doCheckTest(
create(true, "example1.tld", null),
create(true, "example2.tld", null),
create(true, "example3.tld", null));
}
@Test
void testCheck_recentlyDeletedDomain_afterXapEnds_optedIn_returnsAvailable() throws Exception {
// Once the 10-day XAP window expires after deletion (e.g. day 11), checking the domain should
// return available (avail="1") without XAP fees for opted-in registrars.
createTld("tld");
persistResource(
Tld.get("tld")
.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
persistResource(
Registrar.loadByRegistrarId("TheRegistrar")
.get()
.asBuilder()
.setExpiryAccessPeriodEnabled(true)
.build());
Instant deletionTime = clock.now().minus(Duration.ofDays(11));
persistDeletedDomain("example1.tld", deletionTime);
setEppInput("domain_check_one_tld.xml");
doCheckTest(
create(true, "example1.tld", null),
create(true, "example2.tld", null),
create(true, "example3.tld", null));
}
@Test
void testCheck_recentlyDeletedDomain_beforeXapStarts_notOptedIn_returnsAvailable()
throws Exception {
// If a domain was recently deleted but XAP mode is currently disabled on the TLD, checking the
// domain should return available (avail="1") without XAP fees or reservation blocks for
// non-opted-in registrars.
createTld("tld");
persistResource(
Tld.get("tld")
.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.DISABLED))
.build());
persistResource(
Registrar.loadByRegistrarId("TheRegistrar")
.get()
.asBuilder()
.setExpiryAccessPeriodEnabled(false)
.build());
Instant deletionTime = clock.now().minus(Duration.ofDays(5));
persistDeletedDomain("example1.tld", deletionTime);
setEppInput("domain_check_one_tld.xml");
doCheckTest(
create(true, "example1.tld", null),
create(true, "example2.tld", null),
create(true, "example3.tld", null));
}
@Test
void testCheck_recentlyDeletedDomain_beforeXapStarts_optedIn_returnsAvailable() throws Exception {
// If a domain was recently deleted but XAP mode is currently disabled on the TLD, checking the
// domain should return available (avail="1") without XAP fees for opted-in registrars.
createTld("tld");
persistResource(
Tld.get("tld")
.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.DISABLED))
.build());
persistResource(
Registrar.loadByRegistrarId("TheRegistrar")
.get()
.asBuilder()
.setExpiryAccessPeriodEnabled(true)
.build());
Instant deletionTime = clock.now().minus(Duration.ofDays(5));
persistDeletedDomain("example1.tld", deletionTime);
setEppInput("domain_check_one_tld.xml");
doCheckTest(
create(true, "example1.tld", null),
create(true, "example2.tld", null),
create(true, "example3.tld", null));
}
@Test
void testCheck_agpDeletedRegularDomain_duringXap_returnsAvailable() throws Exception {
// A regular domain (registered without an XAP fee) deleted during its Add Grace Period (AGP) is
// exempt from XAP, returning available (avail="1") without reservation blocks or XAP fees when
// checked during active XAP on the TLD.
createTld("tld");
persistResource(
Tld.get("tld")
.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(
START_INSTANT,
Tld.ExpiryAccessPeriodMode.DISABLED,
clock.now().minus(Duration.ofHours(1)),
Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
persistResource(
Registrar.loadByRegistrarId("TheRegistrar")
.get()
.asBuilder()
.setExpiryAccessPeriodEnabled(false)
.build());
Instant creationTime = clock.now().minus(Duration.ofDays(2));
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
DatabaseHelper.persistDomainAsDeleted(
DatabaseHelper.newDomain("example1.tld")
.asBuilder()
.setCreationTimeForTest(creationTime)
.build(),
deletionTime);
setEppInput("domain_check_one_tld.xml");
doCheckTest(
create(true, "example1.tld", null),
create(true, "example2.tld", null),
create(true, "example3.tld", null));
}
@Test
void testCheck_agpDeletedXapDomain_duringXap_returnsAvailable() throws Exception {
// A domain originally registered with an XAP fee deleted during its Add Grace Period (AGP) is
// exempt from XAP, returning available (avail="1") without reservation blocks or XAP fees when
// checked during active XAP on the TLD.
createTld("tld");
persistResource(
Tld.get("tld")
.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
persistResource(
Registrar.loadByRegistrarId("TheRegistrar")
.get()
.asBuilder()
.setExpiryAccessPeriodEnabled(false)
.build());
Instant creationTime = clock.now().minus(Duration.ofHours(2));
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
DatabaseHelper.persistDomainAsDeleted(
DatabaseHelper.newDomain("example1.tld")
.asBuilder()
.setCreationTimeForTest(creationTime)
.build(),
deletionTime);
setEppInput("domain_check_one_tld.xml");
doCheckTest(
create(true, "example1.tld", null),
create(true, "example2.tld", null),
create(true, "example3.tld", null));
}
@Test
void testCheck_anchorTenantDeletedAfterStandardAgp_duringXap_returnsReserved() throws Exception {
// An anchor tenant domain deleted after standard AGP (5 days), e.g. on day 10 of its 30-day
// anchor tenant AGP, is subject to XAP fees and reservation blocks (not exempt as an AGP
// delete).
createTld("tld");
persistResource(
Tld.get("tld")
.asBuilder()
.setAddGracePeriodLength(Duration.ofDays(5))
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
persistResource(
Registrar.loadByRegistrarId("TheRegistrar")
.get()
.asBuilder()
.setExpiryAccessPeriodEnabled(false)
.build());
Instant creationTime = clock.now().minus(Duration.ofDays(10));
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
DatabaseHelper.persistDomainAsDeleted(
DatabaseHelper.newDomain("example1.tld")
.asBuilder()
.setCreationTimeForTest(creationTime)
.build(),
deletionTime);
setEppInput("domain_check_one_tld.xml");
doCheckTest(
create(false, "example1.tld", "Reserved"),
create(true, "example2.tld", null),
create(true, "example3.tld", null));
}
static AllocationToken setUpDefaultToken() {
return setUpDefaultToken("TheRegistrar");
}
@@ -77,6 +77,7 @@ import com.google.common.collect.ImmutableSortedMap;
import com.google.common.collect.Iterables;
import com.google.common.collect.Ordering;
import google.registry.config.RegistryConfig;
import google.registry.config.RegistryConfigSettings;
import google.registry.flows.EppException;
import google.registry.flows.EppException.UnimplementedExtensionException;
import google.registry.flows.EppRequestSource;
@@ -113,6 +114,7 @@ import google.registry.flows.domain.DomainFlowUtils.FeeDescriptionMultipleMatche
import google.registry.flows.domain.DomainFlowUtils.FeeDescriptionParseException;
import google.registry.flows.domain.DomainFlowUtils.FeesMismatchException;
import google.registry.flows.domain.DomainFlowUtils.FeesRequiredDuringEarlyAccessProgramException;
import google.registry.flows.domain.DomainFlowUtils.FeesRequiredDuringExpiryAccessPeriodException;
import google.registry.flows.domain.DomainFlowUtils.FeesRequiredForPremiumNameException;
import google.registry.flows.domain.DomainFlowUtils.InvalidDsRecordException;
import google.registry.flows.domain.DomainFlowUtils.InvalidIdnDomainLabelException;
@@ -2688,6 +2690,427 @@ class DomainCreateFlowTest extends ResourceFlowTestCase<DomainCreateFlow, Domain
+ "during the Early Access Program. The EAP fee is: USD 100.00");
}
@Test
void testFailure_domainInXap_withoutFeeAck_throwsException() throws Exception {
// Creating a domain during an active XAP tier without acknowledging the XAP fee in the EPP fee
// extension should fail with FeesRequiredDuringExpiryAccessPeriodException.
RegistryConfigSettings settings = RegistryConfig.CONFIG_SETTINGS.get();
BigDecimal originalInitialFee =
settings.registryPolicy.domainExpiryAccessPeriod.initialFee.get("USD");
BigDecimal originalFinalFee =
settings.registryPolicy.domainExpiryAccessPeriod.finalFee.get("USD");
settings.registryPolicy.domainExpiryAccessPeriod.initialFee =
ImmutableMap.of("USD", new BigDecimal("100.00"));
settings.registryPolicy.domainExpiryAccessPeriod.finalFee =
ImmutableMap.of("USD", new BigDecimal("100.00"));
try {
persistHosts();
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
setXapForTld("tld", deletionTime);
Exception e =
assertThrows(FeesRequiredDuringExpiryAccessPeriodException.class, this::runFlow);
assertThat(e)
.hasMessageThat()
.isEqualTo(
"Fees must be explicitly acknowledged when creating domains "
+ "during the Expiry Access Period. The XAP fee is: USD 100.00");
} finally {
settings.registryPolicy.domainExpiryAccessPeriod.initialFee =
ImmutableMap.of("USD", originalInitialFee);
settings.registryPolicy.domainExpiryAccessPeriod.finalFee =
ImmutableMap.of("USD", originalFinalFee);
}
}
@Test
void testFailure_domainInXap_registrarNotOptedIn_throwsDomainReservedException()
throws Exception {
// When XAP is enabled on the TLD and the domain is in its XAP window, a registrar that has not
// opted into XAP cannot register the domain and should receive a DomainReservedException.
persistHosts();
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
persistResource(
Tld.get("tld")
.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
persistResource(
Registrar.loadByRegistrarId("TheRegistrar")
.get()
.asBuilder()
.setExpiryAccessPeriodEnabled(false)
.build());
persistResource(
new Domain.Builder()
.setDomainName(getUniqueIdFromCommand())
.setDeletionTime(deletionTime)
.setRegistrationExpirationTime(clock.now().plus(Duration.ofDays(365)))
.setCreationTime(clock.now().minus(Duration.ofDays(365)))
.setCreationRegistrarId("TheRegistrar")
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
.setRepoId("9999-EXAMPLE")
.build());
Exception e = assertThrows(DomainReservedException.class, this::runFlow);
assertThat(e).hasMessageThat().isEqualTo("example.tld is a reserved domain");
}
@Test
void testSuccess_domainInXap_optedIn_withFeeAck_chargesXapFee() throws Exception {
// When an opted-in registrar acknowledges the XAP fee during an active XAP tier, domain
// creation should succeed and persist a one-time XAP fee (Reason.FEE_EXPIRY_ACCESS) in
// addition to the standard domain create fee (Reason.CREATE) and autorenew recurrence
// (Reason.RENEW).
RegistryConfigSettings settings = RegistryConfig.CONFIG_SETTINGS.get();
BigDecimal originalInitialFee =
settings.registryPolicy.domainExpiryAccessPeriod.initialFee.get("USD");
BigDecimal originalFinalFee =
settings.registryPolicy.domainExpiryAccessPeriod.finalFee.get("USD");
settings.registryPolicy.domainExpiryAccessPeriod.initialFee =
ImmutableMap.of("USD", new BigDecimal("100.00"));
settings.registryPolicy.domainExpiryAccessPeriod.finalFee =
ImmutableMap.of("USD", new BigDecimal("100.00"));
try {
persistHosts();
Instant deletionTime = Instant.parse("1999-04-03T21:00:00.0Z");
setXapForTld("tld", deletionTime);
setEppInput(
"domain_create_eap_fee.xml",
new ImmutableMap.Builder<String, String>()
.putAll(FEE_STD_1_0_MAP)
.put("DESCRIPTION_1", "create")
.put("DESCRIPTION_2", "Expiry Access Period")
.build());
runFlowAssertResponse(loadFile("domain_create_response_xap_fee.xml"));
Domain domain = reloadResourceByForeignKey();
DomainHistory historyEntry = getHistoryEntries(domain, DomainHistory.class).get(0);
assertBillingEvents(
new BillingEvent.Builder()
.setReason(Reason.CREATE)
.setTargetId("example.tld")
.setRegistrarId("TheRegistrar")
.setPeriodYears(2)
.setCost(Money.of(USD, 24))
.setEventTime(clock.now())
.setBillingTime(clock.now().plus(Tld.get("tld").getAddGracePeriodLength()))
.setDomainHistory(historyEntry)
.build(),
new BillingEvent.Builder()
.setReason(Reason.FEE_EXPIRY_ACCESS)
.setTargetId("example.tld")
.setRegistrarId("TheRegistrar")
.setPeriodYears(1)
.setCost(Money.of(USD, 100))
.setEventTime(clock.now())
.setBillingTime(clock.now().plus(Tld.get("tld").getAddGracePeriodLength()))
.setDomainHistory(historyEntry)
.build(),
new BillingRecurrence.Builder()
.setReason(Reason.RENEW)
.setFlags(ImmutableSet.of(Flag.AUTO_RENEW))
.setTargetId("example.tld")
.setRegistrarId("TheRegistrar")
.setEventTime(domain.getRegistrationExpirationTime())
.setRecurrenceEndTime(END_INSTANT)
.setDomainHistory(historyEntry)
.build());
} finally {
settings.registryPolicy.domainExpiryAccessPeriod.initialFee =
ImmutableMap.of("USD", originalInitialFee);
settings.registryPolicy.domainExpiryAccessPeriod.finalFee =
ImmutableMap.of("USD", originalFinalFee);
}
}
@Test
void testSuccess_premiumDomainInXap_optedIn_withFeeAck_chargesPremiumAndXapFees()
throws Exception {
// Creating a premium domain during active XAP should stack both fees, charging the premium
// domain create fee (Reason.CREATE) plus the one-time XAP tier fee (Reason.FEE_EXPIRY_ACCESS).
createTld("example");
RegistryConfigSettings settings = RegistryConfig.CONFIG_SETTINGS.get();
BigDecimal originalInitialFee =
settings.registryPolicy.domainExpiryAccessPeriod.initialFee.get("USD");
BigDecimal originalFinalFee =
settings.registryPolicy.domainExpiryAccessPeriod.finalFee.get("USD");
settings.registryPolicy.domainExpiryAccessPeriod.initialFee =
ImmutableMap.of("USD", new BigDecimal("100.00"));
settings.registryPolicy.domainExpiryAccessPeriod.finalFee =
ImmutableMap.of("USD", new BigDecimal("100.00"));
try {
persistHosts();
setEppInput("domain_create_premium_xap.xml");
Instant deletionTime = Instant.parse("1999-04-03T21:00:00.0Z");
setXapForTld("example", deletionTime);
runFlowAssertResponse(loadFile("domain_create_response_premium_xap.xml"));
Domain domain = reloadResourceByForeignKey();
DomainHistory historyEntry = getHistoryEntries(domain, DomainHistory.class).get(0);
assertBillingEvents(
new BillingEvent.Builder()
.setReason(Reason.CREATE)
.setTargetId("rich.example")
.setRegistrarId("TheRegistrar")
.setPeriodYears(2)
.setCost(Money.of(USD, 200))
.setEventTime(clock.now())
.setBillingTime(clock.now().plus(Tld.get("example").getAddGracePeriodLength()))
.setDomainHistory(historyEntry)
.build(),
new BillingEvent.Builder()
.setReason(Reason.FEE_EXPIRY_ACCESS)
.setTargetId("rich.example")
.setRegistrarId("TheRegistrar")
.setPeriodYears(1)
.setCost(Money.of(USD, 100))
.setEventTime(clock.now())
.setBillingTime(clock.now().plus(Tld.get("example").getAddGracePeriodLength()))
.setDomainHistory(historyEntry)
.build(),
new BillingRecurrence.Builder()
.setReason(Reason.RENEW)
.setFlags(ImmutableSet.of(Flag.AUTO_RENEW))
.setTargetId("rich.example")
.setRegistrarId("TheRegistrar")
.setEventTime(domain.getRegistrationExpirationTime())
.setRecurrenceEndTime(END_INSTANT)
.setDomainHistory(historyEntry)
.build());
} finally {
settings.registryPolicy.domainExpiryAccessPeriod.initialFee =
ImmutableMap.of("USD", originalInitialFee);
settings.registryPolicy.domainExpiryAccessPeriod.finalFee =
ImmutableMap.of("USD", originalFinalFee);
}
}
@Test
void testSuccess_recentlyDeletedDomain_afterXapEnds_notOptedIn_succeedsWithoutXapFee()
throws Exception {
// Once the 10-day XAP window expires after deletion (e.g. day 11), the domain returns to
// standard pricing and can be registered without XAP fees or reservation blocks by
// non-opted-in registrars.
persistHosts();
Instant deletionTime = clock.now().minus(Duration.ofDays(11));
persistResource(
Tld.get("tld")
.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
persistResource(
Registrar.loadByRegistrarId("TheRegistrar")
.get()
.asBuilder()
.setExpiryAccessPeriodEnabled(false)
.build());
persistResource(
new Domain.Builder()
.setDomainName(getUniqueIdFromCommand())
.setDeletionTime(deletionTime)
.setRegistrationExpirationTime(clock.now().plus(Duration.ofDays(365)))
.setCreationTime(clock.now().minus(Duration.ofDays(365)))
.setCreationRegistrarId("TheRegistrar")
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
.setRepoId("9999-EXAMPLE")
.build());
doSuccessfulTest();
}
@Test
void testSuccess_recentlyDeletedDomain_afterXapEnds_optedIn_succeedsWithoutXapFee()
throws Exception {
// Once the 10-day XAP window expires after deletion (e.g. day 11), the domain returns to
// standard pricing and can be registered without XAP fees by opted-in registrars.
persistHosts();
Instant deletionTime = clock.now().minus(Duration.ofDays(11));
persistResource(
Tld.get("tld")
.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
persistResource(
Registrar.loadByRegistrarId("TheRegistrar")
.get()
.asBuilder()
.setExpiryAccessPeriodEnabled(true)
.build());
persistResource(
new Domain.Builder()
.setDomainName(getUniqueIdFromCommand())
.setDeletionTime(deletionTime)
.setRegistrationExpirationTime(clock.now().plus(Duration.ofDays(365)))
.setCreationTime(clock.now().minus(Duration.ofDays(365)))
.setCreationRegistrarId("TheRegistrar")
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
.setRepoId("9999-EXAMPLE")
.build());
doSuccessfulTest();
}
@Test
void testSuccess_recentlyDeletedDomain_beforeXapStarts_notOptedIn_succeedsWithoutXapFee()
throws Exception {
// If a domain was recently deleted but XAP mode is currently disabled on the TLD, the domain
// can be registered at standard pricing without XAP fees or reservation blocks by
// non-opted-in registrars.
persistHosts();
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
persistResource(
Tld.get("tld")
.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.DISABLED))
.build());
persistResource(
Registrar.loadByRegistrarId("TheRegistrar")
.get()
.asBuilder()
.setExpiryAccessPeriodEnabled(false)
.build());
persistResource(
new Domain.Builder()
.setDomainName(getUniqueIdFromCommand())
.setDeletionTime(deletionTime)
.setRegistrationExpirationTime(clock.now().plus(Duration.ofDays(365)))
.setCreationTime(clock.now().minus(Duration.ofDays(365)))
.setCreationRegistrarId("TheRegistrar")
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
.setRepoId("9999-EXAMPLE")
.build());
doSuccessfulTest();
}
@Test
void testSuccess_recentlyDeletedDomain_beforeXapStarts_optedIn_succeedsWithoutXapFee()
throws Exception {
// If a domain was recently deleted but XAP mode is currently disabled on the TLD, the domain
// can be registered at standard pricing without XAP fees by opted-in registrars.
persistHosts();
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
persistResource(
Tld.get("tld")
.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.DISABLED))
.build());
persistResource(
Registrar.loadByRegistrarId("TheRegistrar")
.get()
.asBuilder()
.setExpiryAccessPeriodEnabled(true)
.build());
persistResource(
new Domain.Builder()
.setDomainName(getUniqueIdFromCommand())
.setDeletionTime(deletionTime)
.setRegistrationExpirationTime(clock.now().plus(Duration.ofDays(365)))
.setCreationTime(clock.now().minus(Duration.ofDays(365)))
.setCreationRegistrarId("TheRegistrar")
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
.setRepoId("9999-EXAMPLE")
.build());
doSuccessfulTest();
}
@Test
void testSuccess_agpDeletedRegularDomain_duringXap_succeedsWithoutXapFee() throws Exception {
// A regular domain (registered without an XAP fee) that was deleted during its Add Grace
// Period (AGP) is exempt from XAP, succeeding at standard pricing without reservation blocks
// or XAP fees when re-registered during active XAP on the TLD.
persistHosts();
Instant creationTime = clock.now().minus(Duration.ofDays(2));
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
persistResource(
Tld.get("tld")
.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(
START_INSTANT,
Tld.ExpiryAccessPeriodMode.DISABLED,
clock.now().minus(Duration.ofHours(1)),
Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
persistResource(
Registrar.loadByRegistrarId("TheRegistrar")
.get()
.asBuilder()
.setExpiryAccessPeriodEnabled(false)
.build());
persistResource(
new Domain.Builder()
.setDomainName(getUniqueIdFromCommand())
.setCreationTime(creationTime)
.setDeletionTime(deletionTime)
.setRegistrationExpirationTime(clock.now().plus(Duration.ofDays(365)))
.setCreationRegistrarId("TheRegistrar")
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
.setRepoId("9999-EXAMPLE")
.build());
doSuccessfulTest();
}
@Test
void testSuccess_agpDeletedXapDomain_duringXap_succeedsWithoutXapFee() throws Exception {
// A domain originally registered with an XAP fee that was deleted during its Add Grace
// Period (AGP) is exempt from XAP upon subsequent re-registration, succeeding at standard
// pricing without reservation blocks or XAP fees when re-registered during active XAP on the
// TLD.
persistHosts();
Instant creationTime = clock.now().minus(Duration.ofHours(2));
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
persistResource(
Tld.get("tld")
.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
persistResource(
Registrar.loadByRegistrarId("TheRegistrar")
.get()
.asBuilder()
.setExpiryAccessPeriodEnabled(false)
.build());
persistResource(
new Domain.Builder()
.setDomainName(getUniqueIdFromCommand())
.setCreationTime(creationTime)
.setDeletionTime(deletionTime)
.setRegistrationExpirationTime(clock.now().plus(Duration.ofDays(365)))
.setCreationRegistrarId("TheRegistrar")
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
.setRepoId("9999-EXAMPLE")
.build());
doSuccessfulTest();
}
private void setXapForTld(String tldStr, Instant deletionTime) throws Exception {
persistResource(
Registrar.loadByRegistrarId("TheRegistrar")
.get()
.asBuilder()
.setExpiryAccessPeriodEnabled(true)
.build());
persistResource(
Tld.get(tldStr)
.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
persistResource(
new Domain.Builder()
.setDomainName(getUniqueIdFromCommand())
.setDeletionTime(deletionTime)
.setRegistrationExpirationTime(clock.now().plus(Duration.ofDays(365)))
.setCreationTime(clock.now().minus(Duration.ofDays(365)))
.setCreationRegistrarId("TheRegistrar")
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
.setRepoId("9999-EXAMPLE")
.build());
}
private void setEapForTld(String tld) {
persistResource(
Tld.get(tld)
@@ -402,6 +402,36 @@ class DomainDeleteFlowTest extends ResourceFlowTestCase<DomainDeleteFlow, Domain
GracePeriodStatus.ADD, "domain_delete_response_fee.xml", FEE_STD_1_0_MAP);
}
@Test
void testSuccess_addGracePeriodDelete_doesNotRefundXapFee() throws Exception {
setUpSuccessfulTest();
BillingEvent createBillingEvent =
persistResource(createBillingEvent(Reason.CREATE, Money.of(USD, 123)));
BillingEvent xapBillingEvent =
persistResource(createBillingEvent(Reason.FEE_EXPIRY_ACCESS, Money.of(USD, 100)));
setUpGracePeriods(
GracePeriod.forBillingEvent(GracePeriodStatus.ADD, domain.getRepoId(), createBillingEvent));
assertPollMessages(createAutorenewPollMessage("TheRegistrar").build());
clock.advanceOneMilli();
runFlowAssertResponse(loadFile("domain_delete_response_fee.xml", FEE_STD_1_0_MAP));
assertThat(reloadResourceByForeignKey()).isNull();
DomainHistory historyEntryDomainDelete =
getOnlyHistoryEntryOfType(domain, DOMAIN_DELETE, DomainHistory.class);
assertBillingEvents(
createAutorenewBillingEvent("TheRegistrar").setRecurrenceEndTime(clock.now()).build(),
createBillingEvent,
xapBillingEvent,
new BillingCancellation.Builder()
.setReason(Reason.CREATE)
.setTargetId("example.tld")
.setRegistrarId("TheRegistrar")
.setEventTime(clock.now())
.setBillingTime(plusDays(TIME_BEFORE_FLOW, 1))
.setBillingEvent(createBillingEvent.createVKey())
.setDomainHistory(historyEntryDomainDelete)
.build());
}
private void doSuccessfulTest_noAddGracePeriod(String responseFilename) throws Exception {
doSuccessfulTest_noAddGracePeriod(responseFilename, ImmutableMap.of());
}
@@ -30,12 +30,15 @@ import static google.registry.util.DateTimeUtils.END_INSTANT;
import static google.registry.util.DateTimeUtils.START_INSTANT;
import static google.registry.util.DateTimeUtils.minusHours;
import static google.registry.util.DateTimeUtils.plusHours;
import static org.joda.money.CurrencyUnit.JPY;
import static org.joda.money.CurrencyUnit.USD;
import static org.junit.jupiter.api.Assertions.assertThrows;
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.Range;
import google.registry.flows.EppException;
import google.registry.flows.HttpSessionMetadata;
import google.registry.flows.SessionMetadata;
@@ -58,8 +61,10 @@ import google.registry.testing.FakeClock;
import google.registry.testing.FakeHttpSession;
import google.registry.util.Clock;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import org.joda.money.CurrencyUnit;
import org.joda.money.Money;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -85,7 +90,12 @@ public class DomainPricingLogicTest {
createTld("example");
sessionMetadata = new HttpSessionMetadata(new FakeHttpSession());
domainPricingLogic =
new DomainPricingLogic(new DomainPricingCustomLogic(eppInput, sessionMetadata, null));
new DomainPricingLogic(
new DomainPricingCustomLogic(eppInput, sessionMetadata, null),
Duration.ofDays(10),
Duration.ofHours(1),
ImmutableMap.of(USD, new BigDecimal("100000.00"), JPY, new BigDecimal("10000000")),
ImmutableMap.of(USD, new BigDecimal("10.00"), JPY, new BigDecimal("1000")));
tld =
persistResource(
Tld.get("example")
@@ -146,7 +156,14 @@ public class DomainPricingLogicTest {
persistResource(Tld.get("sunrise").asBuilder().setTldStateTransitions(transitions).build());
assertThat(
domainPricingLogic.getCreatePrice(
sunriseTld, "domain.sunrise", clock.now(), 2, false, true, Optional.empty()))
sunriseTld,
"domain.sunrise",
clock.now(),
Optional.empty(),
2,
false,
true,
Optional.empty()))
.isEqualTo(
new FeesAndCredits.Builder()
.setCurrency(USD)
@@ -170,7 +187,14 @@ public class DomainPricingLogicTest {
.build());
assertThat(
domainPricingLogic.getCreatePrice(
tld, "default.example", clock.now(), 1, false, false, Optional.of(allocationToken)))
tld,
"default.example",
clock.now(),
Optional.empty(),
1,
false,
false,
Optional.of(allocationToken)))
.isEqualTo(
new FeesAndCredits.Builder()
.setCurrency(USD)
@@ -195,7 +219,14 @@ public class DomainPricingLogicTest {
// 3 year create should be 5 (discount price) + 10*2 (regular price) = 25.
assertThat(
domainPricingLogic.getCreatePrice(
tld, "default.example", clock.now(), 3, false, false, Optional.of(allocationToken)))
tld,
"default.example",
clock.now(),
Optional.empty(),
3,
false,
false,
Optional.of(allocationToken)))
.isEqualTo(
new FeesAndCredits.Builder()
.setCurrency(USD)
@@ -218,7 +249,14 @@ public class DomainPricingLogicTest {
.build());
assertThat(
domainPricingLogic.getCreatePrice(
tld, "default.example", clock.now(), 1, false, false, Optional.of(allocationToken)))
tld,
"default.example",
clock.now(),
Optional.empty(),
1,
false,
false,
Optional.of(allocationToken)))
.isEqualTo(
new FeesAndCredits.Builder()
.setCurrency(USD)
@@ -1006,7 +1044,14 @@ public class DomainPricingLogicTest {
.build());
assertThat(
domainPricingLogic.getCreatePrice(
tld, "premium.example", clock.now(), 1, false, false, Optional.of(allocationToken)))
tld,
"premium.example",
clock.now(),
Optional.empty(),
1,
false,
false,
Optional.of(allocationToken)))
.isEqualTo(
new FeesAndCredits.Builder()
.setCurrency(USD)
@@ -1015,7 +1060,14 @@ public class DomainPricingLogicTest {
// Two-year create should be 13 (standard price) + 100 (premium price), and it's premium
assertThat(
domainPricingLogic.getCreatePrice(
tld, "premium.example", clock.now(), 2, false, false, Optional.of(allocationToken)))
tld,
"premium.example",
clock.now(),
Optional.empty(),
2,
false,
false,
Optional.of(allocationToken)))
.isEqualTo(
new FeesAndCredits.Builder()
.setCurrency(USD)
@@ -1051,7 +1103,14 @@ public class DomainPricingLogicTest {
// are standard
assertThat(
domainPricingLogic.getCreatePrice(
tld, "premium.example", clock.now(), 2, false, false, Optional.of(allocationToken)))
tld,
"premium.example",
clock.now(),
Optional.empty(),
2,
false,
false,
Optional.of(allocationToken)))
.isEqualTo(
new FeesAndCredits.Builder()
.setCurrency(USD)
@@ -1060,7 +1119,14 @@ public class DomainPricingLogicTest {
// Similarly, 3 years should be 13 + 10 + 10
assertThat(
domainPricingLogic.getCreatePrice(
tld, "premium.example", clock.now(), 3, false, false, Optional.of(allocationToken)))
tld,
"premium.example",
clock.now(),
Optional.empty(),
3,
false,
false,
Optional.of(allocationToken)))
.isEqualTo(
new FeesAndCredits.Builder()
.setCurrency(USD)
@@ -1081,7 +1147,14 @@ public class DomainPricingLogicTest {
// Two-year create should be 100 (premium 1st year) plus 10 (nonpremium 2nd year)
assertThat(
domainPricingLogic.getCreatePrice(
tld, "premium.example", clock.now(), 2, false, false, Optional.of(allocationToken)))
tld,
"premium.example",
clock.now(),
Optional.empty(),
2,
false,
false,
Optional.of(allocationToken)))
.isEqualTo(
new FeesAndCredits.Builder()
.setCurrency(USD)
@@ -1090,7 +1163,14 @@ public class DomainPricingLogicTest {
// Similarly, 3 years should be 100 + 10 + 10
assertThat(
domainPricingLogic.getCreatePrice(
tld, "premium.example", clock.now(), 3, false, false, Optional.of(allocationToken)))
tld,
"premium.example",
clock.now(),
Optional.empty(),
3,
false,
false,
Optional.of(allocationToken)))
.isEqualTo(
new FeesAndCredits.Builder()
.setCurrency(USD)
@@ -1135,4 +1215,291 @@ public class DomainPricingLogicTest {
.getRenewCost())
.isEqualTo(Money.of(USD, 25));
}
@Test
void testGetXapFeeFor_tier0_startOfXap() {
Instant deletionTime = clock.now();
Instant checkTime = deletionTime.plus(Duration.ofMinutes(30));
Optional<Fee> xapFee = domainPricingLogic.getXapFeeFor(checkTime, deletionTime, USD);
assertThat(xapFee)
.hasValue(
Fee.create(
new BigDecimal("100000.00"),
Fee.FeeType.XAP,
false,
Range.closedOpen(deletionTime, deletionTime.plus(Duration.ofHours(1))),
deletionTime.plus(Duration.ofHours(1))));
}
@Test
void testGetXapFeeFor_tier1() {
Instant deletionTime = clock.now();
Instant checkTime = deletionTime.plus(Duration.ofHours(1)).plus(Duration.ofMinutes(15));
Optional<Fee> xapFee = domainPricingLogic.getXapFeeFor(checkTime, deletionTime, USD);
assertThat(xapFee)
.hasValue(
Fee.create(
new BigDecimal("96219.61"),
Fee.FeeType.XAP,
false,
Range.closedOpen(
deletionTime.plus(Duration.ofHours(1)), deletionTime.plus(Duration.ofHours(2))),
deletionTime.plus(Duration.ofHours(2))));
}
@Test
void testGetXapFeeFor_tier239_finalTier() {
Instant deletionTime = clock.now();
Instant checkTime = deletionTime.plus(Duration.ofHours(239)).plus(Duration.ofMinutes(30));
Optional<Fee> xapFee = domainPricingLogic.getXapFeeFor(checkTime, deletionTime, USD);
assertThat(xapFee)
.hasValue(
Fee.create(
new BigDecimal("10.00"),
Fee.FeeType.XAP,
false,
Range.closedOpen(
deletionTime.plus(Duration.ofHours(239)),
deletionTime.plus(Duration.ofHours(240))),
deletionTime.plus(Duration.ofHours(240))));
}
@Test
void testGetXapFeeFor_unconfiguredCurrency() {
Instant deletionTime = clock.now();
Instant checkTime = deletionTime.plus(Duration.ofMinutes(30));
Optional<Fee> xapFee =
domainPricingLogic.getXapFeeFor(checkTime, deletionTime, CurrencyUnit.EUR);
assertThat(xapFee).isEmpty();
}
@Test
void testGetCreatePrice_xapEnabled_includesXapFeeAndRequiresExtension() throws Exception {
Tld xapTld =
persistResource(
tld.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
Domain deletedDomain =
new Domain.Builder()
.setDomainName("deleted.example")
.setDeletionTime(deletionTime)
.setCreationRegistrarId("TheRegistrar")
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
.setRepoId("2-EXAMPLE")
.build();
FeesAndCredits feesAndCredits =
domainPricingLogic.getCreatePrice(
xapTld,
"deleted.example",
clock.now(),
Optional.of(deletedDomain),
1,
false,
false,
Optional.empty());
assertThat(feesAndCredits.isFeeExtensionRequired()).isTrue();
assertThat(feesAndCredits.getXapCost()).isEqualTo(Money.of(USD, new BigDecimal("96219.61")));
assertThat(feesAndCredits.getTotalCost()).isEqualTo(Money.of(USD, new BigDecimal("96232.61")));
}
@Test
void testGetXapFeeFor_afterXapEnds_returnsEmpty() {
Instant deletionTime = clock.now();
Instant checkTime = deletionTime.plus(Duration.ofDays(10)).plus(Duration.ofMinutes(1));
Optional<Fee> xapFee = domainPricingLogic.getXapFeeFor(checkTime, deletionTime, USD);
assertThat(xapFee).isEmpty();
}
@Test
void testGetCreatePrice_agpDeletedRegularDomain_duringXap_noXapFee() throws Exception {
// A regular domain deleted during AGP is exempt from XAP fee evaluation upon subsequent
// creation during active XAP.
Tld xapTld =
persistResource(
tld.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(
START_INSTANT,
Tld.ExpiryAccessPeriodMode.DISABLED,
clock.now().minus(Duration.ofHours(1)),
Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
Instant creationTime = clock.now().minus(Duration.ofDays(2));
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
Domain deletedDomain =
new Domain.Builder()
.setDomainName("deleted.example")
.setCreationTime(creationTime)
.setDeletionTime(deletionTime)
.setCreationRegistrarId("TheRegistrar")
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
.setRepoId("2-EXAMPLE")
.build();
FeesAndCredits feesAndCredits =
domainPricingLogic.getCreatePrice(
xapTld,
"deleted.example",
clock.now(),
Optional.of(deletedDomain),
1,
false,
false,
Optional.empty());
assertThat(feesAndCredits.isFeeExtensionRequired()).isFalse();
assertThat(feesAndCredits.getXapCost()).isEqualTo(Money.zero(USD));
assertThat(feesAndCredits.getTotalCost()).isEqualTo(Money.of(USD, new BigDecimal("13.00")));
}
@Test
void testGetCreatePrice_agpDeletedXapDomain_duringXap_noXapFee() throws Exception {
// A domain originally registered with an XAP fee deleted during AGP is exempt from XAP fee
// evaluation upon subsequent creation during active XAP.
Tld xapTld =
persistResource(
tld.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
Instant creationTime = clock.now().minus(Duration.ofHours(2));
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
Domain deletedDomain =
new Domain.Builder()
.setDomainName("deleted.example")
.setCreationTime(creationTime)
.setDeletionTime(deletionTime)
.setCreationRegistrarId("TheRegistrar")
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
.setRepoId("2-EXAMPLE")
.build();
FeesAndCredits feesAndCredits =
domainPricingLogic.getCreatePrice(
xapTld,
"deleted.example",
clock.now(),
Optional.of(deletedDomain),
1,
false,
false,
Optional.empty());
assertThat(feesAndCredits.isFeeExtensionRequired()).isFalse();
assertThat(feesAndCredits.getXapCost()).isEqualTo(Money.zero(USD));
assertThat(feesAndCredits.getTotalCost()).isEqualTo(Money.of(USD, new BigDecimal("13.00")));
}
@Test
void testGetCreatePrice_anchorTenantDeletedAfterStandardAgp_duringXap_chargesXapFee()
throws Exception {
// An anchor tenant domain deleted after standard AGP (5 days), e.g. on day 10 of its 30-day
// anchor tenant AGP, is subject to XAP fees upon re-registration (not exempt as an AGP delete).
Tld xapTld =
persistResource(
tld.asBuilder()
.setAddGracePeriodLength(Duration.ofDays(5))
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
Instant creationTime = clock.now().minus(Duration.ofDays(10));
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
Domain deletedDomain =
new Domain.Builder()
.setDomainName("deleted.example")
.setCreationTime(creationTime)
.setDeletionTime(deletionTime)
.setCreationRegistrarId("TheRegistrar")
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
.setRepoId("2-EXAMPLE")
.build();
FeesAndCredits feesAndCredits =
domainPricingLogic.getCreatePrice(
xapTld,
"deleted.example",
clock.now(),
Optional.of(deletedDomain),
1,
false,
false,
Optional.empty());
assertThat(feesAndCredits.isFeeExtensionRequired()).isTrue();
assertThat(feesAndCredits.getXapCost()).isEqualTo(Money.of(USD, new BigDecimal("96219.61")));
}
@Test
void testGetCreatePrice_premiumDomainInXap_chargesPremiumAndXapFees() throws Exception {
// Calculating create price for a recently deleted premium domain during active XAP should sum
// both the premium create fee and the one-time XAP tier fee.
Tld xapTld =
persistResource(
tld.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
Domain deletedDomain =
new Domain.Builder()
.setDomainName("premium.example")
.setDeletionTime(deletionTime)
.setCreationRegistrarId("TheRegistrar")
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
.setRepoId("2-EXAMPLE")
.build();
FeesAndCredits feesAndCredits =
domainPricingLogic.getCreatePrice(
xapTld,
"premium.example",
clock.now(),
Optional.of(deletedDomain),
1,
false,
false,
Optional.empty());
assertThat(feesAndCredits.hasAnyPremiumFees()).isTrue();
assertThat(feesAndCredits.isFeeExtensionRequired()).isTrue();
assertThat(feesAndCredits.getCreateCost()).isEqualTo(Money.of(USD, new BigDecimal("100.00")));
assertThat(feesAndCredits.getXapCost()).isEqualTo(Money.of(USD, new BigDecimal("96219.61")));
assertThat(feesAndCredits.getTotalCost()).isEqualTo(Money.of(USD, new BigDecimal("96319.61")));
}
@Test
void testGetCreatePrice_zeroXapFee_doesNotRequireExtension() throws Exception {
// When an XAP tier fee is $0.00, it is included in the fee items but does not require a fee
// extension acknowledgment from the registrar.
Tld xapTld =
persistResource(
tld.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(START_INSTANT, Tld.ExpiryAccessPeriodMode.ENABLED))
.build());
DomainPricingLogic zeroFeePricingLogic =
new DomainPricingLogic(
new DomainPricingCustomLogic(eppInput, sessionMetadata, null),
Duration.ofDays(10),
Duration.ofHours(1),
ImmutableMap.of(USD, BigDecimal.ZERO),
ImmutableMap.of(USD, BigDecimal.ZERO));
Instant deletionTime = clock.now().minus(Duration.ofHours(1));
Domain deletedDomain =
new Domain.Builder()
.setDomainName("deleted.example")
.setDeletionTime(deletionTime)
.setCreationRegistrarId("TheRegistrar")
.setPersistedCurrentSponsorRegistrarId("TheRegistrar")
.setRepoId("2-EXAMPLE")
.build();
FeesAndCredits feesAndCredits =
zeroFeePricingLogic.getCreatePrice(
xapTld,
"deleted.example",
clock.now(),
Optional.of(deletedDomain),
1,
false,
false,
Optional.empty());
assertThat(feesAndCredits.isFeeExtensionRequired()).isFalse();
assertThat(feesAndCredits.getXapCost()).isEqualTo(Money.zero(USD));
assertThat(feesAndCredits.getTotalCost()).isEqualTo(Money.of(USD, new BigDecimal("13.00")));
}
}
@@ -36,6 +36,7 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableSortedMap;
import com.google.common.net.InternetDomainName;
@@ -54,6 +55,7 @@ import google.registry.model.tld.Tld;
import google.registry.persistence.transaction.JpaTestExtensions;
import google.registry.persistence.transaction.JpaTestExtensions.JpaIntegrationTestExtension;
import google.registry.testing.FakeClock;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
@@ -73,7 +75,12 @@ class AllocationTokenFlowUtilsTest {
mock(AllocationTokenExtension.class);
private final DomainPricingLogic domainPricingLogic =
new DomainPricingLogic(new DomainPricingCustomLogic(null, null, null));
new DomainPricingLogic(
new DomainPricingCustomLogic(null, null, null),
Duration.ofDays(30),
Duration.ofDays(1),
ImmutableMap.of(),
ImmutableMap.of());
private Tld tld;
@@ -773,4 +773,20 @@ class RegistrarTest extends EntityTestCase {
assertThat(Registrar.loadByRegistrarId("registrar").toString())
.contains("allowedTlds=[bar, baz, foo, gon, tri]");
}
@Test
void testSuccess_expiryAccessPeriodEnabled() {
assertThat(registrar.getExpiryAccessPeriodEnabled()).isFalse();
persistResource(registrar.asBuilder().setExpiryAccessPeriodEnabled(true).build());
assertThat(
Registrar.loadByRegistrarId("registrar").orElseThrow().getExpiryAccessPeriodEnabled())
.isTrue();
}
@Test
void testSuccess_toJsonMap_includesExpiryAccessPeriodEnabled() {
assertThat(registrar.toJsonMap()).containsEntry("expiryAccessPeriodEnabled", false);
Registrar modified = registrar.asBuilder().setExpiryAccessPeriodEnabled(true).build();
assertThat(modified.toJsonMap()).containsEntry("expiryAccessPeriodEnabled", true);
}
}
@@ -746,6 +746,37 @@ public final class TldTest extends EntityTestCase {
.isEqualTo(new BigDecimal("50.00"));
}
@Test
void testExpiryAccessPeriodMode_undefined() {
assertThat(Tld.get("tld").getExpiryAccessPeriodModeAt(fakeClock.now()))
.isEqualTo(Tld.ExpiryAccessPeriodMode.DISABLED);
}
@Test
void testExpiryAccessPeriodMode_specified() {
Instant a = minusDays(fakeClock.now(), 1);
Instant b = plusDays(fakeClock.now(), 1);
Tld tld =
Tld.get("tld")
.asBuilder()
.setExpiryAccessPeriodTransitions(
ImmutableSortedMap.of(
START_INSTANT,
Tld.ExpiryAccessPeriodMode.DISABLED,
a,
Tld.ExpiryAccessPeriodMode.ENABLED,
b,
Tld.ExpiryAccessPeriodMode.DISABLED))
.build();
assertThat(tld.getExpiryAccessPeriodModeAt(fakeClock.now()))
.isEqualTo(Tld.ExpiryAccessPeriodMode.ENABLED);
assertThat(tld.getExpiryAccessPeriodModeAt(minusDays(fakeClock.now(), 2)))
.isEqualTo(Tld.ExpiryAccessPeriodMode.DISABLED);
assertThat(tld.getExpiryAccessPeriodModeAt(plusDays(fakeClock.now(), 2)))
.isEqualTo(Tld.ExpiryAccessPeriodMode.DISABLED);
}
@Test
void testFailure_eapFee_wrongCurrency() {
IllegalArgumentException thrown =
@@ -0,0 +1,54 @@
<epp xmlns="urn:ietf:params:xml:ns:epp-1.0">
<response>
<result code="1000">
<msg>Command completed successfully</msg>
</result>
<resData>
<domain:chkData xmlns:domain="urn:ietf:params:xml:ns:domain-1.0">
<domain:cd>
<domain:name avail="true">example1.tld</domain:name>
</domain:cd>
<domain:cd>
<domain:name avail="true">example2.tld</domain:name>
</domain:cd>
<domain:cd>
<domain:name avail="true">example3.tld</domain:name>
</domain:cd>
</domain:chkData>
</resData>
<extension>
<fee:chkData xmlns:fee="urn:ietf:params:xml:ns:epp:fee-1.0">
<fee:currency>USD</fee:currency>
<fee:cd>
<fee:objID>example1.tld</fee:objID>
<fee:class>standard</fee:class>
<fee:command name="create">
<fee:period unit="y">1</fee:period>
<fee:fee description="create">13.00</fee:fee>
<fee:fee description="Expiry Access Period, fee expires: 2009-01-06T01:00:00Z">110.00</fee:fee>
</fee:command>
</fee:cd>
<fee:cd>
<fee:objID>example2.tld</fee:objID>
<fee:class>standard</fee:class>
<fee:command name="create">
<fee:period unit="y">1</fee:period>
<fee:fee description="create">13.00</fee:fee>
</fee:command>
</fee:cd>
<fee:cd>
<fee:objID>example3.tld</fee:objID>
<fee:class>standard</fee:class>
<fee:command name="create">
<fee:period unit="y">1</fee:period>
<fee:fee description="create">13.00</fee:fee>
</fee:command>
</fee:cd>
</fee:chkData>
</extension>
<trID>
<clTRID>ABC-12345</clTRID>
<svTRID>server-trid</svTRID>
</trID>
</response>
</epp>
@@ -0,0 +1,26 @@
<epp xmlns="urn:ietf:params:xml:ns:epp-1.0">
<command>
<create>
<domain:create
xmlns:domain="urn:ietf:params:xml:ns:domain-1.0">
<domain:name>rich.example</domain:name>
<domain:period unit="y">2</domain:period>
<domain:ns>
<domain:hostObj>ns1.example.net</domain:hostObj>
<domain:hostObj>ns2.example.net</domain:hostObj>
</domain:ns>
<domain:authInfo>
<domain:pw>2fooBAR</domain:pw>
</domain:authInfo>
</domain:create>
</create>
<extension>
<fee:create xmlns:fee="urn:ietf:params:xml:ns:epp:fee-1.0">
<fee:currency>USD</fee:currency>
<fee:fee description="create">200.00</fee:fee>
<fee:fee description="Expiry Access Period">100.00</fee:fee>
</fee:create>
</extension>
<clTRID>ABC-12345</clTRID>
</command>
</epp>
@@ -0,0 +1,26 @@
<epp xmlns="urn:ietf:params:xml:ns:epp-1.0">
<response>
<result code="1000">
<msg>Command completed successfully</msg>
</result>
<resData>
<domain:creData
xmlns:domain="urn:ietf:params:xml:ns:domain-1.0">
<domain:name>rich.example</domain:name>
<domain:crDate>1999-04-03T22:00:00.0Z</domain:crDate>
<domain:exDate>2001-04-03T22:00:00.0Z</domain:exDate>
</domain:creData>
</resData>
<extension>
<fee:creData xmlns:fee="urn:ietf:params:xml:ns:epp:fee-1.0">
<fee:currency>USD</fee:currency>
<fee:fee description="create">200.00</fee:fee>
<fee:fee description="Expiry Access Period, fee expires: 1999-04-03T23:00:00Z">100.00</fee:fee>
</fee:creData>
</extension>
<trID>
<clTRID>ABC-12345</clTRID>
<svTRID>server-trid</svTRID>
</trID>
</response>
</epp>
@@ -0,0 +1,26 @@
<epp xmlns="urn:ietf:params:xml:ns:epp-1.0">
<response>
<result code="1000">
<msg>Command completed successfully</msg>
</result>
<resData>
<domain:creData
xmlns:domain="urn:ietf:params:xml:ns:domain-1.0">
<domain:name>example.tld</domain:name>
<domain:crDate>1999-04-03T22:00:00.0Z</domain:crDate>
<domain:exDate>2001-04-03T22:00:00.0Z</domain:exDate>
</domain:creData>
</resData>
<extension>
<fee:creData xmlns:fee="urn:ietf:params:xml:ns:epp:fee-1.0">
<fee:currency>USD</fee:currency>
<fee:fee description="create">24.00</fee:fee>
<fee:fee description="Expiry Access Period, fee expires: 1999-04-03T23:00:00Z">100.00</fee:fee>
</fee:creData>
</extension>
<trID>
<clTRID>ABC-12345</clTRID>
<svTRID>server-trid</svTRID>
</trID>
</response>
</epp>
@@ -32,6 +32,8 @@ eapFeeSchedule:
currency: "USD"
amount: 0.00
escrowEnabled: false
expiryAccessPeriodTransitions:
"1970-01-01T00:00:00.000Z": "DISABLED"
idnTables:
- "EXTENDED_LATIN"
- "JA"
@@ -19,6 +19,8 @@ eapFeeSchedule:
currency: "USD"
amount: 0.00
escrowEnabled: false
expiryAccessPeriodTransitions:
"1970-01-01T00:00:00.000Z": "DISABLED"
idnTables: []
invoicingEnabled: false
lordnUsername: null
@@ -23,6 +23,8 @@ eapFeeSchedule:
currency: "USD"
amount: 0.00
escrowEnabled: false
expiryAccessPeriodTransitions:
"1970-01-01T00:00:00.000Z": "DISABLED"
idnTables:
- "foo"
invoicingEnabled: false
@@ -19,6 +19,8 @@ eapFeeSchedule:
currency: "USD"
amount: 0.00
escrowEnabled: false
expiryAccessPeriodTransitions:
"1970-01-01T00:00:00.000Z": "DISABLED"
idnTables: []
invoicingEnabled: false
lordnUsername: null
@@ -23,6 +23,8 @@ eapFeeSchedule:
currency: "USD"
amount: 0.00
escrowEnabled: false
expiryAccessPeriodTransitions:
"1970-01-01T00:00:00.000Z": "DISABLED"
idnTables: []
invoicingEnabled: false
lordnUsername: null
@@ -23,6 +23,8 @@ eapFeeSchedule:
currency: "USD"
amount: 0.00
escrowEnabled: false
expiryAccessPeriodTransitions:
"1970-01-01T00:00:00.000Z": "DISABLED"
idnTables: []
invoicingEnabled: false
lordnUsername: null
@@ -27,6 +27,8 @@ eapFeeSchedule:
currency: "USD"
amount: 0.00
escrowEnabled: false
expiryAccessPeriodTransitions:
"1970-01-01T00:00:00.000Z": "DISABLED"
idnTables:
- "EXTENDED_LATIN"
- "JA"
@@ -23,6 +23,8 @@ eapFeeSchedule:
currency: "JPY"
amount: 0
escrowEnabled: false
expiryAccessPeriodTransitions:
"1970-01-01T00:00:00.000Z": "DISABLED"
idnTables: []
invoicingEnabled: false
lordnUsername: null
@@ -22,6 +22,8 @@ eapFeeSchedule:
currency: "USD"
amount: 0.00
escrowEnabled: false
expiryAccessPeriodTransitions:
"1970-01-01T00:00:00.000Z": "DISABLED"
idnTables: []
invoicingEnabled: false
lordnUsername: null
@@ -26,6 +26,8 @@ eapFeeSchedule:
currency: "USD"
amount: 0.00
escrowEnabled: false
expiryAccessPeriodTransitions:
"1970-01-01T00:00:00.000Z": "DISABLED"
idnTables: []
invoicingEnabled: false
lordnUsername: null
@@ -18,6 +18,8 @@ reservedListNames: null
premiumListName: null
pricingEngineClassName: "google.registry.model.pricing.StaticPremiumListPricingEngine"
escrowEnabled: false
expiryAccessPeriodTransitions:
"1970-01-01T00:00:00.000Z": "DISABLED"
dnsPaused: false
addGracePeriodLength: "PT720H"
anchorTenantAddGracePeriodLength: "PT720H"
@@ -5,6 +5,8 @@ reservedListNames: []
dnsPaused: false
tldType: "REAL"
escrowEnabled: false
expiryAccessPeriodTransitions:
"1970-01-01T00:00:00.000Z": "DISABLED"
anchorTenantAddGracePeriodLength: "PT720H"
dnsNsTtl: null
tldStr: "outoforderfields"
@@ -26,6 +26,8 @@ eapFeeSchedule:
currency: "USD"
amount: 0.00
escrowEnabled: false
expiryAccessPeriodTransitions:
"1970-01-01T00:00:00.000Z": "DISABLED"
idnTables: []
invoicingEnabled: false
lordnUsername: null
@@ -23,6 +23,8 @@ eapFeeSchedule:
currency: "USD"
amount: 0.00
escrowEnabled: false
expiryAccessPeriodTransitions:
"1970-01-01T00:00:00.000Z": "DISABLED"
idnTables: []
invoicingEnabled: false
lordnUsername: null
@@ -17,6 +17,8 @@ creationTime: "2022-09-01T00:00:00.000Z"
reservedListNames: []
premiumListName: "test"
escrowEnabled: false
expiryAccessPeriodTransitions:
"1970-01-01T00:00:00.000Z": "DISABLED"
dnsPaused: false
addGracePeriodLength: 432000000
anchorTenantAddGracePeriodLength: 2592000000