diff --git a/core/src/main/java/google/registry/config/RegistryConfig.java b/core/src/main/java/google/registry/config/RegistryConfig.java index 44b3e23e3c3..cfbf40c122c 100644 --- a/core/src/main/java/google/registry/config/RegistryConfig.java +++ b/core/src/main/java/google/registry/config/RegistryConfig.java @@ -16,6 +16,7 @@ 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.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.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 static String provideRegistryAdminClientId(RegistryConfigSettings config) 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 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 provideDomainExpiryAccessPeriodFinalFee( + RegistryConfigSettings config) { + return config.registryPolicy.domainExpiryAccessPeriod.finalFee.entrySet().stream() + .collect(toImmutableMap(entry -> CurrencyUnit.of(entry.getKey()), Entry::getValue)); + } + @Singleton @Provides static RegistryConfigSettings provideRegistryConfigSettings() { diff --git a/core/src/main/java/google/registry/config/RegistryConfigSettings.java b/core/src/main/java/google/registry/config/RegistryConfigSettings.java index 5b8ddedadcd..455d298f483 100644 --- a/core/src/main/java/google/registry/config/RegistryConfigSettings.java +++ b/core/src/main/java/google/registry/config/RegistryConfigSettings.java @@ -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 static class RegistryPolicy { 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 static class RegistryPolicy { public Set noPollMessageOnDeletionRegistrarIds; } + public static class DomainExpiryAccessPeriod { + public long totalLengthSeconds; + public long tierLengthSeconds; + public Map initialFee; + public Map finalFee; + } + /** Configuration for Hibernate. */ public static class Hibernate { public boolean allowNestedTransactions; diff --git a/core/src/main/java/google/registry/config/files/default-config.yaml b/core/src/main/java/google/registry/config/files/default-config.yaml index 9b8719d7511..42270ea5730 100644 --- a/core/src/main/java/google/registry/config/files/default-config.yaml +++ b/core/src/main/java/google/registry/config/files/default-config.yaml @@ -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 diff --git a/core/src/main/java/google/registry/flows/ResourceFlowUtils.java b/core/src/main/java/google/registry/flows/ResourceFlowUtils.java index 174af643b6b..029d0ba7d82 100644 --- a/core/src/main/java/google/registry/flows/ResourceFlowUtils.java +++ b/core/src/main/java/google/registry/flows/ResourceFlowUtils.java @@ -46,6 +46,7 @@ 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 static R verifyExistence( public static void verifyResourceDoesNotExist( Class clazz, String targetId, Instant now, String registrarId) throws EppException { - Optional 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. + * + *

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). + * + *

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 Optional verifyResourceDoesNotExist( + Class clazz, String targetId, Instant now, Duration lookback, String registrarId) + throws EppException { + Instant startOfWindow = now.minus(lookback); + Optional 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 static void verifyResourceDoesNotExist( throw new ResourceCreateContentionException(targetId); } } + return Optional.empty(); } /** Check that the given AuthInfo is present for a resource being transferred. */ diff --git a/core/src/main/java/google/registry/flows/domain/DomainCheckFlow.java b/core/src/main/java/google/registry/flows/domain/DomainCheckFlow.java index 24147e9eec2..e3e93a5da27 100644 --- a/core/src/main/java/google/registry/flows/domain/DomainCheckFlow.java +++ b/core/src/main/java/google/registry/flows/domain/DomainCheckFlow.java @@ -25,6 +25,7 @@ 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.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.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 @@ private Optional getMessageForCheck( return Optional.of(e.getMessage()); } return getMessageForCheckWithToken( - idn, existingDomains, bsaBlockedDomainNames, tldStates, token); + idn, existingDomains, bsaBlockedDomainNames, tldStates, token, now); + } + + private static Optional 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 getMessageForCheckWithToken( @@ -257,10 +271,18 @@ private Optional getMessageForCheckWithToken( ImmutableMap> existingDomains, ImmutableSet bsaBlockedDomains, ImmutableMap tldStates, - Optional allocationToken) { + Optional 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 @@ private ImmutableList getResponseExtensions( .build()); continue; } + Optional domainForFee = domain; + boolean isAvailable = availableDomains.contains(domainName); + if (isAvailable + && feeCheckItem.getCommandName().equals(FeeQueryCommandExtensionItem.CommandName.CREATE) + && tld.getExpiryAccessPeriodModeAt(now) == Tld.ExpiryAccessPeriodMode.ENABLED) { + Optional 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 diff --git a/core/src/main/java/google/registry/flows/domain/DomainCreateFlow.java b/core/src/main/java/google/registry/flows/domain/DomainCreateFlow.java index a36690edf3a..3d408ae918f 100644 --- a/core/src/main/java/google/registry/flows/domain/DomainCreateFlow.java +++ b/core/src/main/java/google/registry/flows/domain/DomainCreateFlow.java @@ -18,6 +18,7 @@ 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.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.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.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.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 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 EppResponse run() throws EppException { 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 recentlyDeletedDomain = + verifyResourceDoesNotExist(Domain.class, targetId, now, lookback, registrarId) + .filter(domain -> isDomainEligibleForXap(domain, tld, now)); validateCreateCommandContactsAndNameservers(command, tld, domainName); TldState tldState = tld.getTldState(now); Optional launchCreate = @@ -280,6 +297,10 @@ public EppResponse run() throws EppException { 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 EppResponse run() throws EppException { 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 secDnsCreate = validateSecDnsExtension(eppInput.getSingleExtension(SecDnsCreateExtension.class)); @@ -358,7 +386,15 @@ public EppResponse run() throws EppException { 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 reservationTypes = getReservationTypes(domainName); @@ -434,7 +470,14 @@ public EppResponse run() throws EppException { 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 @@ private void verifyDomainDoesNotExist() throws ResourceCreateContentionException } } - 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()) diff --git a/core/src/main/java/google/registry/flows/domain/DomainFlowUtils.java b/core/src/main/java/google/registry/flows/domain/DomainFlowUtils.java index fff9bfa826c..33a88fa1c17 100644 --- a/core/src/main/java/google/registry/flows/domain/DomainFlowUtils.java +++ b/core/src/main/java/google/registry/flows/domain/DomainFlowUtils.java @@ -642,6 +642,7 @@ static void handleFeeRequest( tld, domainNameString, now, + domain, years, isAnchorTenant(domainName, allocationToken, Optional.empty()), isSunrise, @@ -765,6 +766,9 @@ public static void validateFeesAckedIfPresent( 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 @@ private static List findRecentHistoryEntries( .getResultList(); } + /** + * Returns true if the domain was deleted during its Add Grace Period (AGP). + * + *

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 resourceIds) { @@ -1355,6 +1385,18 @@ public FeesRequiredDuringEarlyAccessProgramException(Money expectedFee) { } } + /** 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() { diff --git a/core/src/main/java/google/registry/flows/domain/DomainPricingLogic.java b/core/src/main/java/google/registry/flows/domain/DomainPricingLogic.java index df65c8e347b..533eb3cc683 100644 --- a/core/src/main/java/google/registry/flows/domain/DomainPricingLogic.java +++ b/core/src/main/java/google/registry/flows/domain/DomainPricingLogic.java @@ -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.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.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 @@ */ 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 expiryAccessPeriodInitialFee; + private final ImmutableMap expiryAccessPeriodFinalFee; @Inject - public DomainPricingLogic(DomainPricingCustomLogic customLogic) { + public DomainPricingLogic( + DomainPricingCustomLogic customLogic, + @Config("domainExpiryAccessPeriodTotalLength") Duration expiryAccessPeriodTotalLength, + @Config("domainExpiryAccessPeriodTierLength") Duration expiryAccessPeriodTierLength, + @Config("domainExpiryAccessPeriodInitialFee") + ImmutableMap expiryAccessPeriodInitialFee, + @Config("domainExpiryAccessPeriodFinalFee") + ImmutableMap expiryAccessPeriodFinalFee) { this.customLogic = customLogic; + this.expiryAccessPeriodTotalLength = expiryAccessPeriodTotalLength; + this.expiryAccessPeriodTierLength = expiryAccessPeriodTierLength; + this.expiryAccessPeriodInitialFee = expiryAccessPeriodInitialFee; + this.expiryAccessPeriodFinalFee = expiryAccessPeriodFinalFee; } /** @@ -71,35 +96,23 @@ public FeesAndCredits getCreatePrice( Tld tld, String domainName, Instant dateTime, + Optional recentlyDeletedDomain, int years, boolean isAnchorTenant, boolean isSunriseCreate, Optional 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 FeesAndCredits getCreatePrice( 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 FeesAndCredits getCreatePrice( .build()); } + private Fee computeCreateFee( + Tld tld, + String domainName, + Instant dateTime, + int years, + boolean isAnchorTenant, + boolean isSunriseCreate, + Optional 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 recentlyDeletedDomain, + CurrencyUnit currency, + FeesAndCredits.Builder feesBuilder) { + if (tld.getExpiryAccessPeriodModeAt(dateTime) == Tld.ExpiryAccessPeriodMode.ENABLED + && recentlyDeletedDomain.isPresent() + && isDomainEligibleForXap(recentlyDeletedDomain.get(), tld, dateTime)) { + Optional 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 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 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, diff --git a/core/src/main/java/google/registry/flows/domain/FeesAndCredits.java b/core/src/main/java/google/registry/flows/domain/FeesAndCredits.java index b65aa89096c..84c45aecf76 100644 --- a/core/src/main/java/google/registry/flows/domain/FeesAndCredits.java +++ b/core/src/main/java/google/registry/flows/domain/FeesAndCredits.java @@ -77,6 +77,11 @@ public Money getEapCost() { 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); diff --git a/core/src/main/java/google/registry/flows/domain/token/AllocationTokenFlowUtils.java b/core/src/main/java/google/registry/flows/domain/token/AllocationTokenFlowUtils.java index c6141e7daee..702cf6339f3 100644 --- a/core/src/main/java/google/registry/flows/domain/token/AllocationTokenFlowUtils.java +++ b/core/src/main/java/google/registry/flows/domain/token/AllocationTokenFlowUtils.java @@ -242,7 +242,14 @@ private static BigDecimal getSampleCostWithToken( 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 -> diff --git a/core/src/main/java/google/registry/model/EntityYamlUtils.java b/core/src/main/java/google/registry/model/EntityYamlUtils.java index 611c4ef97d7..c84a874268b 100644 --- a/core/src/main/java/google/registry/model/EntityYamlUtils.java +++ b/core/src/main/java/google/registry/model/EntityYamlUtils.java @@ -21,6 +21,7 @@ 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 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 TimedTransitionPropertyTldStateDeserializer(Class deserialize( JsonParser jp, DeserializationContext context) throws IOException { - SortedMap valueMap = jp.readValueAs(SortedMap.class); + SortedMap valueMap = + jp.readValueAs(new TypeReference>() {}); return TimedTransitionProperty.fromValueMap( valueMap.keySet().stream() .collect( @@ -347,6 +350,37 @@ public TimedTransitionProperty deserialize( } } + /** + * A custom JSON deserializer for a {@link TimedTransitionProperty} of {@link + * ExpiryAccessPeriodMode}. + */ + public static class TimedTransitionPropertyExpiryAccessPeriodModeDeserializer + extends StdDeserializer> { + + public TimedTransitionPropertyExpiryAccessPeriodModeDeserializer() { + this(null); + } + + public TimedTransitionPropertyExpiryAccessPeriodModeDeserializer( + Class> t) { + super(t); + } + + @Override + public TimedTransitionProperty deserialize( + JsonParser jp, DeserializationContext context) throws IOException { + SortedMap valueMap = + jp.readValueAs(new TypeReference>() {}); + 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> { @@ -362,7 +396,8 @@ public TimedTransitionPropertyMoneyDeserializer(Class deserialize(JsonParser jp, DeserializationContext context) throws IOException { - SortedMap> valueMap = jp.readValueAs(SortedMap.class); + SortedMap> valueMap = + jp.readValueAs(new TypeReference>>() {}); return TimedTransitionProperty.fromValueMap( valueMap.keySet().stream() .collect( @@ -392,7 +427,8 @@ public TimedTransitionPropertyFeatureStatusDeserializer( @Override public TimedTransitionProperty deserialize( JsonParser jp, DeserializationContext context) throws IOException { - SortedMap valueMap = jp.readValueAs(SortedMap.class); + SortedMap valueMap = + jp.readValueAs(new TypeReference>() {}); return TimedTransitionProperty.fromValueMap( valueMap.keySet().stream() .collect( diff --git a/core/src/main/java/google/registry/model/billing/BillingBase.java b/core/src/main/java/google/registry/model/billing/BillingBase.java index f242a9aebf5..5726143708f 100644 --- a/core/src/main/java/google/registry/model/billing/BillingBase.java +++ b/core/src/main/java/google/registry/model/billing/BillingBase.java @@ -48,6 +48,7 @@ public enum Reason { @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), diff --git a/core/src/main/java/google/registry/model/billing/BillingEvent.java b/core/src/main/java/google/registry/model/billing/BillingEvent.java index 6fab00e6b12..b63ac26d898 100644 --- a/core/src/main/java/google/registry/model/billing/BillingEvent.java +++ b/core/src/main/java/google/registry/model/billing/BillingEvent.java @@ -200,7 +200,7 @@ public BillingEvent build() { 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), diff --git a/core/src/main/java/google/registry/model/domain/fee/BaseFee.java b/core/src/main/java/google/registry/model/domain/fee/BaseFee.java index aaa84ad2b48..390566a8cef 100644 --- a/core/src/main/java/google/registry/model/domain/fee/BaseFee.java +++ b/core/src/main/java/google/registry/model/domain/fee/BaseFee.java @@ -51,6 +51,10 @@ public enum AppliedType { 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"), /** diff --git a/core/src/main/java/google/registry/model/registrar/Registrar.java b/core/src/main/java/google/registry/model/registrar/Registrar.java index 14418a748d4..e1f5d0e96c4 100644 --- a/core/src/main/java/google/registry/model/registrar/Registrar.java +++ b/core/src/main/java/google/registry/model/registrar/Registrar.java @@ -265,6 +265,18 @@ public enum State { @Column(nullable = false) boolean blockPremiumNames; + /** + * Whether this registrar has opted into participating in the Expiry Access Period (XAP). + * + *

If this is false, any domain currently in XAP will appear as reserved/unavailable on checks + * and creates. + * + *

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 boolean getBlockPremiumNames() { return blockPremiumNames; } + public boolean getExpiryAccessPeriodEnabled() { + return expiryAccessPeriodEnabled; + } + public boolean getContactsRequireSyncing() { return contactsRequireSyncing; } @@ -654,6 +670,7 @@ public Map toJsonMap() { .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 Builder setBlockPremiumNames(boolean blockPremiumNames) { return this; } + public Builder setExpiryAccessPeriodEnabled(boolean expiryAccessPeriodEnabled) { + getInstance().expiryAccessPeriodEnabled = expiryAccessPeriodEnabled; + return this; + } + public Builder setUrl(String url) { getInstance().url = url; return this; diff --git a/core/src/main/java/google/registry/model/tld/Tld.java b/core/src/main/java/google/registry/model/tld/Tld.java index 94ceb4b5aaf..46e9430dc95 100644 --- a/core/src/main/java/google/registry/model/tld/Tld.java +++ b/core/src/main/java/google/registry/model/tld/Tld.java @@ -56,6 +56,7 @@ 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.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 enum TldState { 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 ImmutableSet getReservedListNames() { @Column(nullable = false) boolean dnsPaused = DEFAULT_DNS_PAUSED; + /** + * Whether the Expiry Access Period following domain deletes is enabled for this TLD. + * + *

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 expiryAccessPeriodTransitions = + TimedTransitionProperty.withInitialValue(DEFAULT_EXPIRY_ACCESS_PERIOD_MODE); + /** * The length of the add grace period for this TLD. * @@ -634,6 +657,14 @@ public boolean getDnsPaused() { return dnsPaused; } + public ExpiryAccessPeriodMode getExpiryAccessPeriodModeAt(Instant now) { + return expiryAccessPeriodTransitions.getValueAtTime(now); + } + + public ImmutableSortedMap getExpiryAccessPeriodTransitions() { + return expiryAccessPeriodTransitions.toValueMap(); + } + @Nullable public String getDriveFolderId() { return driveFolderId; @@ -830,6 +861,7 @@ public void validateState() { createBillingCostTransitions.checkValidity(); renewBillingCostTransitions.checkValidity(); eapFeeSchedule.checkValidity(); + expiryAccessPeriodTransitions.checkValidity(); // All costs must be in the expected currency. checkArgumentNotNull(getCurrency(), "Currency must be set"); Predicate currencyCheck = @@ -913,6 +945,13 @@ public Builder setDnsPaused(boolean paused) { return this; } + public Builder setExpiryAccessPeriodTransitions( + ImmutableSortedMap transitionsMap) { + getInstance().expiryAccessPeriodTransitions = + TimedTransitionProperty.fromValueMap(transitionsMap); + return this; + } + public Builder setDriveFolderId(String driveFolderId) { getInstance().driveFolderId = driveFolderId; return this; diff --git a/core/src/main/java/google/registry/persistence/converter/ExpiryAccessPeriodModeTransitionUserType.java b/core/src/main/java/google/registry/persistence/converter/ExpiryAccessPeriodModeTransitionUserType.java new file mode 100644 index 00000000000..bdb0464ba6b --- /dev/null +++ b/core/src/main/java/google/registry/persistence/converter/ExpiryAccessPeriodModeTransitionUserType.java @@ -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 { + + @Override + String valueToString(ExpiryAccessPeriodMode value) { + return value.name(); + } + + @Override + ExpiryAccessPeriodMode stringToValue(String string) { + return ExpiryAccessPeriodMode.valueOf(string); + } +} diff --git a/core/src/test/java/google/registry/flows/domain/DomainCheckFlowTest.java b/core/src/test/java/google/registry/flows/domain/DomainCheckFlowTest.java index e5bc3eee2a6..4b9d2fd149d 100644 --- a/core/src/test/java/google/registry/flows/domain/DomainCheckFlowTest.java +++ b/core/src/test/java/google/registry/flows/domain/DomainCheckFlowTest.java @@ -49,6 +49,8 @@ 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.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 @@ private void runEapFeeCheckTestWithXmlInputOutput(String inputXml, String output 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"); } diff --git a/core/src/test/java/google/registry/flows/domain/DomainCreateFlowTest.java b/core/src/test/java/google/registry/flows/domain/DomainCreateFlowTest.java index 59487d96ec5..537fa6a091d 100644 --- a/core/src/test/java/google/registry/flows/domain/DomainCreateFlowTest.java +++ b/core/src/test/java/google/registry/flows/domain/DomainCreateFlowTest.java @@ -77,6 +77,7 @@ 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.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 @@ void testFailure_domainInEap_failsWithoutFeeExtension() { + "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() + .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) diff --git a/core/src/test/java/google/registry/flows/domain/DomainDeleteFlowTest.java b/core/src/test/java/google/registry/flows/domain/DomainDeleteFlowTest.java index 469313b57a0..e51cf8e957f 100644 --- a/core/src/test/java/google/registry/flows/domain/DomainDeleteFlowTest.java +++ b/core/src/test/java/google/registry/flows/domain/DomainDeleteFlowTest.java @@ -402,6 +402,36 @@ void testSuccess_addGracePeriodCredit_std_v1() throws Exception { 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()); } diff --git a/core/src/test/java/google/registry/flows/domain/DomainPricingLogicTest.java b/core/src/test/java/google/registry/flows/domain/DomainPricingLogicTest.java index a2a83c595d5..cfb9b8265a5 100644 --- a/core/src/test/java/google/registry/flows/domain/DomainPricingLogicTest.java +++ b/core/src/test/java/google/registry/flows/domain/DomainPricingLogicTest.java @@ -30,12 +30,15 @@ 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.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 @@ void beforeEach() throws Exception { 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 @@ void testGetDomainCreatePrice_sunrise_appliesDiscount() throws EppException { 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 @@ void testGetDomainCreatePrice_discountPriceAllocationToken_oneYearCreate_applies .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 @@ void testGetDomainCreatePrice_discountPriceAllocationToken_multiYearCreate_appli // 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 @@ void testGetDomainCreatePrice_discountPriceAllocationToken_oneYearCreate_moreDis .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 @@ void testGetDomainCreatePrice_nonPremiumCreate_unaffectedRenewal() throws EppExc .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 @@ void testGetDomainCreatePrice_nonPremiumCreate_unaffectedRenewal() throws EppExc // 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 @@ void testGetDomainCreatePrice_premium_multiYear_nonpremiumCreateAndRenewal() thr // 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 @@ void testGetDomainCreatePrice_premium_multiYear_nonpremiumCreateAndRenewal() thr // 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 @@ void testGetDomainCreatePrice_premium_multiYear_onlyNonpremiumRenewal() throws E // 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 @@ void testGetDomainCreatePrice_premium_multiYear_onlyNonpremiumRenewal() throws E // 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 @@ void testDomainRenewPrice_specifiedToken_multiYear() throws Exception { .getRenewCost()) .isEqualTo(Money.of(USD, 25)); } + + @Test + void testGetXapFeeFor_tier0_startOfXap() { + Instant deletionTime = clock.now(); + Instant checkTime = deletionTime.plus(Duration.ofMinutes(30)); + Optional 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 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 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 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 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"))); + } } diff --git a/core/src/test/java/google/registry/flows/domain/token/AllocationTokenFlowUtilsTest.java b/core/src/test/java/google/registry/flows/domain/token/AllocationTokenFlowUtilsTest.java index b72361a5220..6bdcc061777 100644 --- a/core/src/test/java/google/registry/flows/domain/token/AllocationTokenFlowUtilsTest.java +++ b/core/src/test/java/google/registry/flows/domain/token/AllocationTokenFlowUtilsTest.java @@ -36,6 +36,7 @@ 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.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; diff --git a/core/src/test/java/google/registry/model/registrar/RegistrarTest.java b/core/src/test/java/google/registry/model/registrar/RegistrarTest.java index ba01b62dd16..5dae85c9fb7 100644 --- a/core/src/test/java/google/registry/model/registrar/RegistrarTest.java +++ b/core/src/test/java/google/registry/model/registrar/RegistrarTest.java @@ -773,4 +773,20 @@ void testToString_sortsAllowedTlds() { 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); + } } diff --git a/core/src/test/java/google/registry/model/tld/TldTest.java b/core/src/test/java/google/registry/model/tld/TldTest.java index b721f62aee2..41945ef54fe 100644 --- a/core/src/test/java/google/registry/model/tld/TldTest.java +++ b/core/src/test/java/google/registry/model/tld/TldTest.java @@ -746,6 +746,37 @@ void testEapFee_specified() { .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 = diff --git a/core/src/test/resources/google/registry/flows/domain/domain_check_fee_xap_response.xml b/core/src/test/resources/google/registry/flows/domain/domain_check_fee_xap_response.xml new file mode 100644 index 00000000000..6becfe736fb --- /dev/null +++ b/core/src/test/resources/google/registry/flows/domain/domain_check_fee_xap_response.xml @@ -0,0 +1,54 @@ + + + + Command completed successfully + + + + + example1.tld + + + example2.tld + + + example3.tld + + + + + + USD + + example1.tld + standard + + 1 + 13.00 + 110.00 + + + + example2.tld + standard + + 1 + 13.00 + + + + example3.tld + standard + + 1 + 13.00 + + + + + + ABC-12345 + server-trid + + + diff --git a/core/src/test/resources/google/registry/flows/domain/domain_create_premium_xap.xml b/core/src/test/resources/google/registry/flows/domain/domain_create_premium_xap.xml new file mode 100644 index 00000000000..774b9b46cef --- /dev/null +++ b/core/src/test/resources/google/registry/flows/domain/domain_create_premium_xap.xml @@ -0,0 +1,26 @@ + + + + + rich.example + 2 + + ns1.example.net + ns2.example.net + + + 2fooBAR + + + + + + USD + 200.00 + 100.00 + + + ABC-12345 + + diff --git a/core/src/test/resources/google/registry/flows/domain/domain_create_response_premium_xap.xml b/core/src/test/resources/google/registry/flows/domain/domain_create_response_premium_xap.xml new file mode 100644 index 00000000000..7dedb453571 --- /dev/null +++ b/core/src/test/resources/google/registry/flows/domain/domain_create_response_premium_xap.xml @@ -0,0 +1,26 @@ + + + + Command completed successfully + + + + rich.example + 1999-04-03T22:00:00.0Z + 2001-04-03T22:00:00.0Z + + + + + USD + 200.00 + 100.00 + + + + ABC-12345 + server-trid + + + diff --git a/core/src/test/resources/google/registry/flows/domain/domain_create_response_xap_fee.xml b/core/src/test/resources/google/registry/flows/domain/domain_create_response_xap_fee.xml new file mode 100644 index 00000000000..2ef5549a27d --- /dev/null +++ b/core/src/test/resources/google/registry/flows/domain/domain_create_response_xap_fee.xml @@ -0,0 +1,26 @@ + + + + Command completed successfully + + + + example.tld + 1999-04-03T22:00:00.0Z + 2001-04-03T22:00:00.0Z + + + + + USD + 24.00 + 100.00 + + + + ABC-12345 + server-trid + + + diff --git a/core/src/test/resources/google/registry/model/tld/tld.yaml b/core/src/test/resources/google/registry/model/tld/tld.yaml index 38fe0784788..24ec1c51d9b 100644 --- a/core/src/test/resources/google/registry/model/tld/tld.yaml +++ b/core/src/test/resources/google/registry/model/tld/tld.yaml @@ -32,6 +32,8 @@ eapFeeSchedule: currency: "USD" amount: 0.00 escrowEnabled: false +expiryAccessPeriodTransitions: + "1970-01-01T00:00:00.000Z": "DISABLED" idnTables: - "EXTENDED_LATIN" - "JA" diff --git a/core/src/test/resources/google/registry/tools/1tld.yaml b/core/src/test/resources/google/registry/tools/1tld.yaml index 7c75c05fd72..f87ae91fbe0 100644 --- a/core/src/test/resources/google/registry/tools/1tld.yaml +++ b/core/src/test/resources/google/registry/tools/1tld.yaml @@ -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 diff --git a/core/src/test/resources/google/registry/tools/badidn.yaml b/core/src/test/resources/google/registry/tools/badidn.yaml index 68222832778..5ee95034031 100644 --- a/core/src/test/resources/google/registry/tools/badidn.yaml +++ b/core/src/test/resources/google/registry/tools/badidn.yaml @@ -23,6 +23,8 @@ eapFeeSchedule: currency: "USD" amount: 0.00 escrowEnabled: false +expiryAccessPeriodTransitions: + "1970-01-01T00:00:00.000Z": "DISABLED" idnTables: - "foo" invoicingEnabled: false diff --git a/core/src/test/resources/google/registry/tools/badunicode.yaml b/core/src/test/resources/google/registry/tools/badunicode.yaml index 2a8fdf87e00..3a5339397ca 100644 --- a/core/src/test/resources/google/registry/tools/badunicode.yaml +++ b/core/src/test/resources/google/registry/tools/badunicode.yaml @@ -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 diff --git a/core/src/test/resources/google/registry/tools/diffcostmap.yaml b/core/src/test/resources/google/registry/tools/diffcostmap.yaml index 1039960e231..8203e898ac9 100644 --- a/core/src/test/resources/google/registry/tools/diffcostmap.yaml +++ b/core/src/test/resources/google/registry/tools/diffcostmap.yaml @@ -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 diff --git a/core/src/test/resources/google/registry/tools/extrafield.yaml b/core/src/test/resources/google/registry/tools/extrafield.yaml index 2e1b2e453c3..e765c78dcaa 100644 --- a/core/src/test/resources/google/registry/tools/extrafield.yaml +++ b/core/src/test/resources/google/registry/tools/extrafield.yaml @@ -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 diff --git a/core/src/test/resources/google/registry/tools/idns.yaml b/core/src/test/resources/google/registry/tools/idns.yaml index c9c60076e0c..571c238e62d 100644 --- a/core/src/test/resources/google/registry/tools/idns.yaml +++ b/core/src/test/resources/google/registry/tools/idns.yaml @@ -27,6 +27,8 @@ eapFeeSchedule: currency: "USD" amount: 0.00 escrowEnabled: false +expiryAccessPeriodTransitions: + "1970-01-01T00:00:00.000Z": "DISABLED" idnTables: - "EXTENDED_LATIN" - "JA" diff --git a/core/src/test/resources/google/registry/tools/jpy.yaml b/core/src/test/resources/google/registry/tools/jpy.yaml index 0c45144e021..3fa96fca846 100644 --- a/core/src/test/resources/google/registry/tools/jpy.yaml +++ b/core/src/test/resources/google/registry/tools/jpy.yaml @@ -23,6 +23,8 @@ eapFeeSchedule: currency: "JPY" amount: 0 escrowEnabled: false +expiryAccessPeriodTransitions: + "1970-01-01T00:00:00.000Z": "DISABLED" idnTables: [] invoicingEnabled: false lordnUsername: null diff --git a/core/src/test/resources/google/registry/tools/missingnullablefields.yaml b/core/src/test/resources/google/registry/tools/missingnullablefields.yaml index 0f3aec9650f..559ff5bbb2d 100644 --- a/core/src/test/resources/google/registry/tools/missingnullablefields.yaml +++ b/core/src/test/resources/google/registry/tools/missingnullablefields.yaml @@ -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 diff --git a/core/src/test/resources/google/registry/tools/negativecost.yaml b/core/src/test/resources/google/registry/tools/negativecost.yaml index c2a7654a58d..97426502fa0 100644 --- a/core/src/test/resources/google/registry/tools/negativecost.yaml +++ b/core/src/test/resources/google/registry/tools/negativecost.yaml @@ -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 diff --git a/core/src/test/resources/google/registry/tools/nullablefieldsallnull.yaml b/core/src/test/resources/google/registry/tools/nullablefieldsallnull.yaml index a585d3b24fa..58d82281e3d 100644 --- a/core/src/test/resources/google/registry/tools/nullablefieldsallnull.yaml +++ b/core/src/test/resources/google/registry/tools/nullablefieldsallnull.yaml @@ -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" diff --git a/core/src/test/resources/google/registry/tools/outoforderfields.yaml b/core/src/test/resources/google/registry/tools/outoforderfields.yaml index c9010153a50..aaa3dd4a5c6 100644 --- a/core/src/test/resources/google/registry/tools/outoforderfields.yaml +++ b/core/src/test/resources/google/registry/tools/outoforderfields.yaml @@ -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" diff --git a/core/src/test/resources/google/registry/tools/tld.yaml b/core/src/test/resources/google/registry/tools/tld.yaml index 50dd3b29534..ac1108dcf55 100644 --- a/core/src/test/resources/google/registry/tools/tld.yaml +++ b/core/src/test/resources/google/registry/tools/tld.yaml @@ -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 diff --git a/core/src/test/resources/google/registry/tools/wildcard.yaml b/core/src/test/resources/google/registry/tools/wildcard.yaml index ab5665de305..cc8527dfcea 100644 --- a/core/src/test/resources/google/registry/tools/wildcard.yaml +++ b/core/src/test/resources/google/registry/tools/wildcard.yaml @@ -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 diff --git a/core/src/test/resources/google/registry/tools/wrongcurrency.yaml b/core/src/test/resources/google/registry/tools/wrongcurrency.yaml index 29be8ca5fe3..ef87d070873 100644 --- a/core/src/test/resources/google/registry/tools/wrongcurrency.yaml +++ b/core/src/test/resources/google/registry/tools/wrongcurrency.yaml @@ -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 diff --git a/db/src/main/resources/sql/schema/db-schema.sql.generated b/db/src/main/resources/sql/schema/db-schema.sql.generated index f6521283abb..870bfff1b8f 100644 --- a/db/src/main/resources/sql/schema/db-schema.sql.generated +++ b/db/src/main/resources/sql/schema/db-schema.sql.generated @@ -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,