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:
@@ -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;
|
||||
|
||||
+33
@@ -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")));
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -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 =
|
||||
|
||||
+54
@@ -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>
|
||||
+26
@@ -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>
|
||||
+26
@@ -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
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
domain_repo_id text not null,
|
||||
event_time timestamp(6) with time zone not null,
|
||||
flags text[],
|
||||
reason text not null check ((reason in ('CREATE','ERROR','FEE_EARLY_ACCESS','RENEW','RESTORE','SERVER_STATUS','TRANSFER'))),
|
||||
reason text not null check ((reason in ('CREATE','ERROR','FEE_EARLY_ACCESS','FEE_EXPIRY_ACCESS','RENEW','RESTORE','SERVER_STATUS','TRANSFER'))),
|
||||
domain_name text not null,
|
||||
billing_event_id bigint,
|
||||
billing_recurrence_id bigint,
|
||||
@@ -58,7 +58,7 @@
|
||||
domain_repo_id text not null,
|
||||
event_time timestamp(6) with time zone not null,
|
||||
flags text[],
|
||||
reason text not null check ((reason in ('CREATE','ERROR','FEE_EARLY_ACCESS','RENEW','RESTORE','SERVER_STATUS','TRANSFER'))),
|
||||
reason text not null check ((reason in ('CREATE','ERROR','FEE_EARLY_ACCESS','FEE_EXPIRY_ACCESS','RENEW','RESTORE','SERVER_STATUS','TRANSFER'))),
|
||||
domain_name text not null,
|
||||
allocation_token text,
|
||||
billing_time timestamp(6) with time zone,
|
||||
@@ -78,7 +78,7 @@
|
||||
domain_repo_id text not null,
|
||||
event_time timestamp(6) with time zone not null,
|
||||
flags text[],
|
||||
reason text not null check ((reason in ('CREATE','ERROR','FEE_EARLY_ACCESS','RENEW','RESTORE','SERVER_STATUS','TRANSFER'))),
|
||||
reason text not null check ((reason in ('CREATE','ERROR','FEE_EARLY_ACCESS','FEE_EXPIRY_ACCESS','RENEW','RESTORE','SERVER_STATUS','TRANSFER'))),
|
||||
domain_name text not null,
|
||||
recurrence_end_time timestamp(6) with time zone,
|
||||
recurrence_last_expansion timestamp(6) with time zone not null,
|
||||
@@ -507,6 +507,7 @@
|
||||
creation_time timestamp(6) with time zone not null,
|
||||
drive_folder_id text,
|
||||
email_address text,
|
||||
expiry_access_period_enabled boolean not null,
|
||||
failover_client_certificate text,
|
||||
failover_client_certificate_hash text,
|
||||
fax_number text,
|
||||
@@ -646,6 +647,7 @@
|
||||
drive_folder_id text,
|
||||
eap_fee_schedule hstore not null,
|
||||
escrow_enabled boolean not null,
|
||||
expiry_access_period_transitions hstore not null,
|
||||
idn_tables text[],
|
||||
invoicing_enabled boolean not null,
|
||||
lordn_username text,
|
||||
|
||||
Reference in New Issue
Block a user