diff --git a/java/google/registry/flows/domain/DomainDeleteFlow.java b/java/google/registry/flows/domain/DomainDeleteFlow.java index a0c55e226..4f260a92b 100644 --- a/java/google/registry/flows/domain/DomainDeleteFlow.java +++ b/java/google/registry/flows/domain/DomainDeleteFlow.java @@ -24,15 +24,20 @@ import static google.registry.flows.ResourceFlowUtils.verifyNoDisallowedStatuses import static google.registry.flows.ResourceFlowUtils.verifyOptionalAuthInfo; import static google.registry.flows.ResourceFlowUtils.verifyResourceOwnership; import static google.registry.flows.domain.DomainFlowUtils.checkAllowedAccessToTld; +import static google.registry.flows.domain.DomainFlowUtils.createCancellingRecords; import static google.registry.flows.domain.DomainFlowUtils.updateAutorenewRecurrenceEndTime; import static google.registry.flows.domain.DomainFlowUtils.verifyNotInPredelegation; import static google.registry.model.eppoutput.Result.Code.SUCCESS; import static google.registry.model.eppoutput.Result.Code.SUCCESS_WITH_ACTION_PENDING; import static google.registry.model.ofy.ObjectifyService.ofy; +import static google.registry.model.reporting.DomainTransactionRecord.TransactionReportField.isAddsField; +import static google.registry.model.reporting.DomainTransactionRecord.TransactionReportField.isRenewsField; import static google.registry.pricing.PricingEngineProxy.getDomainRenewCost; import static google.registry.util.CollectionUtils.nullToEmpty; +import static google.registry.util.CollectionUtils.union; import com.google.common.base.Optional; +import com.google.common.base.Predicate; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; import com.googlecode.objectify.Key; @@ -77,14 +82,18 @@ import google.registry.model.poll.PendingActionNotificationResponse.DomainPendin import google.registry.model.poll.PollMessage; import google.registry.model.poll.PollMessage.OneTime; import google.registry.model.registry.Registry; +import google.registry.model.reporting.DomainTransactionRecord; +import google.registry.model.reporting.DomainTransactionRecord.TransactionReportField; import google.registry.model.reporting.HistoryEntry; import google.registry.model.reporting.IcannReportingTypes.ActivityReportField; import google.registry.model.transfer.TransferStatus; +import java.util.Collections; import java.util.Set; import javax.annotation.Nullable; import javax.inject.Inject; import org.joda.money.Money; import org.joda.time.DateTime; +import org.joda.time.Duration; /** * An EPP flow that deletes a domain. @@ -133,24 +142,30 @@ public final class DomainDeleteFlow implements TransactionalFlow { customLogic.afterValidation( AfterValidationParameters.newBuilder().setExistingDomain(existingDomain).build()); ImmutableSet.Builder entitiesToSave = new ImmutableSet.Builder<>(); - HistoryEntry historyEntry = buildHistoryEntry(existingDomain, now); Builder builder = existingDomain.getStatusValues().contains(StatusValue.PENDING_TRANSFER) ? ResourceFlowUtils.resolvePendingTransfer( existingDomain, TransferStatus.SERVER_CANCELLED, now) : existingDomain.asBuilder(); - builder.setDeletionTime(now).setStatusValues(null); - // If the domain is in the Add Grace Period, we delete it immediately, which is already - // reflected in the builder we just prepared. Otherwise we give it a PENDING_DELETE status. - if (!existingDomain.getGracePeriodStatuses().contains(GracePeriodStatus.ADD)) { - // By default, this should be 30 days of grace, and 5 days of pending delete. - DateTime deletionTime = now - .plus(registry.getRedemptionGracePeriodLength()) - .plus(registry.getPendingDeleteLength()); + boolean inAddGracePeriod = + existingDomain.getGracePeriodStatuses().contains(GracePeriodStatus.ADD); + // If the domain is in the Add Grace Period, we delete it immediately. + // Otherwise, we give it a PENDING_DELETE status. + Duration durationUntilDelete = + inAddGracePeriod + ? Duration.ZERO + // By default, this should be 30 days of grace, and 5 days of pending delete. + : registry.getRedemptionGracePeriodLength().plus(registry.getPendingDeleteLength()); + HistoryEntry historyEntry = buildHistoryEntry( + existingDomain, registry, now, durationUntilDelete, inAddGracePeriod); + if (inAddGracePeriod) { + builder.setDeletionTime(now).setStatusValues(null); + } else { + DateTime deletionTime = now.plus(durationUntilDelete); PollMessage.OneTime deletePollMessage = createDeletePollMessage(existingDomain, historyEntry, deletionTime); entitiesToSave.add(deletePollMessage); - builder.setStatusValues(ImmutableSet.of(StatusValue.PENDING_DELETE)) - .setDeletionTime(deletionTime) + builder.setDeletionTime(deletionTime) + .setStatusValues(ImmutableSet.of(StatusValue.PENDING_DELETE)) // Clear out all old grace periods and add REDEMPTION, which does not include a key to a // billing event because there isn't one for a domain delete. .setGracePeriods(ImmutableSet.of(GracePeriod.createWithoutBillingEvent( @@ -218,11 +233,43 @@ public final class DomainDeleteFlow implements TransactionalFlow { } } - private HistoryEntry buildHistoryEntry(DomainResource existingResource, DateTime now) { + private HistoryEntry buildHistoryEntry( + DomainResource existingResource, + Registry registry, + DateTime now, + Duration durationUntilDelete, + boolean inAddGracePeriod) { + Duration maxGracePeriod = Collections.max( + ImmutableSet.of( + registry.getAddGracePeriodLength(), + registry.getAutoRenewGracePeriodLength(), + registry.getRenewGracePeriodLength())); + ImmutableSet cancelledRecords = + createCancellingRecords( + existingResource, + now, + maxGracePeriod, + new Predicate() { + @Override + public boolean apply(@Nullable DomainTransactionRecord domainTransactionRecord) { + return isAddsField(domainTransactionRecord.getReportField()) + || isRenewsField(domainTransactionRecord.getReportField()); + } + }); return historyBuilder .setType(HistoryEntry.Type.DOMAIN_DELETE) .setModificationTime(now) .setParent(Key.create(existingResource)) + .setDomainTransactionRecords( + union( + cancelledRecords, + DomainTransactionRecord.create( + existingResource.getTld(), + now.plus(durationUntilDelete), + inAddGracePeriod + ? TransactionReportField.DELETED_DOMAINS_GRACE + : TransactionReportField.DELETED_DOMAINS_NOGRACE, + 1))) .build(); } diff --git a/java/google/registry/flows/domain/DomainFlowUtils.java b/java/google/registry/flows/domain/DomainFlowUtils.java index ead7026af..e11ce3d63 100644 --- a/java/google/registry/flows/domain/DomainFlowUtils.java +++ b/java/google/registry/flows/domain/DomainFlowUtils.java @@ -42,7 +42,9 @@ import static google.registry.util.FormattingLogger.getLoggerForCallerClass; import com.google.common.base.CharMatcher; import com.google.common.base.Joiner; import com.google.common.base.Optional; +import com.google.common.base.Predicate; import com.google.common.base.Splitter; +import com.google.common.collect.FluentIterable; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; @@ -105,6 +107,7 @@ import google.registry.model.registry.Registry; import google.registry.model.registry.Registry.TldState; import google.registry.model.registry.label.ReservationType; import google.registry.model.registry.label.ReservedList; +import google.registry.model.reporting.DomainTransactionRecord; import google.registry.model.reporting.HistoryEntry; import google.registry.model.tmch.ClaimsListShard; import google.registry.util.FormattingLogger; @@ -118,6 +121,7 @@ import javax.annotation.Nullable; import org.joda.money.CurrencyUnit; import org.joda.money.Money; import org.joda.time.DateTime; +import org.joda.time.Duration; /** Static utility functions for domain flows. */ public class DomainFlowUtils { @@ -909,6 +913,56 @@ public class DomainFlowUtils { return builder.build(); } + /** + * Returns a set of DomainTransactionRecords which negate the most recent HistoryEntry's records. + * + *

Domain deletes and transfers use this function to account for previous records negated by + * their flow. For example, if a grace period delete occurs, we must add -1 counters for the + * associated NET_ADDS_#_YRS field, if it exists. + * + *

The steps are as follows: 1. Find all HistoryEntries under the domain modified in the past, + * up to the maxSearchPeriod. 2. Only keep HistoryEntries with a DomainTransactionRecord that a) + * hasn't been reported yet and b) matches the predicate 3. Return the transactionRecords under + * the most recent HistoryEntry that fits the above criteria, with negated reportAmounts. + */ + static ImmutableSet createCancellingRecords( + DomainResource domainResource, + final DateTime now, + Duration maxSearchPeriod, + final Predicate isCancellable) { + + List recentHistoryEntries = ofy().load() + .type(HistoryEntry.class) + .ancestor(domainResource) + .filter("modificationTime >=", now.minus(maxSearchPeriod)) + .order("modificationTime") + .list(); + Optional entryToCancel = FluentIterable.from(recentHistoryEntries) + .filter( + new Predicate() { + @Override + public boolean apply(HistoryEntry historyEntry) { + // Look for add and renew transaction records that have yet to be reported + for (DomainTransactionRecord record : historyEntry.getDomainTransactionRecords()) { + if (isCancellable.apply(record) && record.getReportingTime().isAfter(now)) { + return true; + } + } + return false; + } + }) + // We only want to cancel out the most recent add or renewal + .last(); + ImmutableSet.Builder recordsBuilder = new ImmutableSet.Builder<>(); + if (entryToCancel.isPresent()) { + for (DomainTransactionRecord record : entryToCancel.get().getDomainTransactionRecords()) { + int cancelledAmount = -1 * record.getReportAmount(); + recordsBuilder.add(record.asBuilder().setReportAmount(cancelledAmount).build()); + } + } + return recordsBuilder.build(); + } + /** Resource linked to this domain does not exist. */ static class LinkedResourcesDoNotExistException extends ObjectDoesNotExistException { public LinkedResourcesDoNotExistException(Class type, ImmutableSet resourceIds) { diff --git a/java/google/registry/model/reporting/DomainTransactionRecord.java b/java/google/registry/model/reporting/DomainTransactionRecord.java index 69395aab6..3dddd393d 100644 --- a/java/google/registry/model/reporting/DomainTransactionRecord.java +++ b/java/google/registry/model/reporting/DomainTransactionRecord.java @@ -17,6 +17,7 @@ package google.registry.model.reporting; import static com.google.common.base.Preconditions.checkArgument; import static google.registry.util.PreconditionsUtils.checkArgumentNotNull; +import com.google.common.collect.ImmutableSet; import com.googlecode.objectify.annotation.Embed; import google.registry.model.Buildable; import google.registry.model.ImmutableObject; @@ -114,6 +115,42 @@ public class DomainTransactionRecord extends ImmutableObject implements Buildabl return nameToField("NET_RENEWS_%d_YR", years); } + private static final ImmutableSet ADD_FIELDS = + ImmutableSet.of( + NET_ADDS_1_YR, + NET_ADDS_2_YR, + NET_ADDS_3_YR, + NET_ADDS_4_YR, + NET_ADDS_5_YR, + NET_ADDS_6_YR, + NET_ADDS_7_YR, + NET_ADDS_8_YR, + NET_ADDS_9_YR, + NET_ADDS_10_YR); + + private static final ImmutableSet RENEW_FIELDS = + ImmutableSet.of( + NET_RENEWS_1_YR, + NET_RENEWS_2_YR, + NET_RENEWS_3_YR, + NET_RENEWS_4_YR, + NET_RENEWS_5_YR, + NET_RENEWS_6_YR, + NET_RENEWS_7_YR, + NET_RENEWS_8_YR, + NET_RENEWS_9_YR, + NET_RENEWS_10_YR); + + + /** Boilerplate to determine if a field is one of the NET_ADDS fields. */ + public static boolean isAddsField(TransactionReportField field) { + return ADD_FIELDS.contains(field); + } + /** Boilerplate to determine if a field is one of the NET_ADDS fields. */ + public static boolean isRenewsField(TransactionReportField field) { + return RENEW_FIELDS.contains(field); + } + private static TransactionReportField nameToField(String enumTemplate, int years) { checkArgument( years >= 1 && years <= 10, "domain add and renew years must be between 1 and 10"); diff --git a/javatests/google/registry/flows/domain/DomainDeleteFlowTest.java b/javatests/google/registry/flows/domain/DomainDeleteFlowTest.java index 57381e01c..f34e01c57 100644 --- a/javatests/google/registry/flows/domain/DomainDeleteFlowTest.java +++ b/javatests/google/registry/flows/domain/DomainDeleteFlowTest.java @@ -18,6 +18,15 @@ import static com.google.common.truth.Truth.assertThat; import static google.registry.flows.domain.DomainTransferFlowTestCase.persistWithPendingTransfer; import static google.registry.model.EppResourceUtils.loadByForeignKey; import static google.registry.model.ofy.ObjectifyService.ofy; +import static google.registry.model.reporting.DomainTransactionRecord.TransactionReportField.DELETED_DOMAINS_GRACE; +import static google.registry.model.reporting.DomainTransactionRecord.TransactionReportField.DELETED_DOMAINS_NOGRACE; +import static google.registry.model.reporting.DomainTransactionRecord.TransactionReportField.NET_ADDS_10_YR; +import static google.registry.model.reporting.DomainTransactionRecord.TransactionReportField.NET_ADDS_1_YR; +import static google.registry.model.reporting.DomainTransactionRecord.TransactionReportField.NET_RENEWS_3_YR; +import static google.registry.model.reporting.DomainTransactionRecord.TransactionReportField.RESTORED_DOMAINS; +import static google.registry.model.reporting.HistoryEntry.Type.DOMAIN_CREATE; +import static google.registry.model.reporting.HistoryEntry.Type.DOMAIN_DELETE; +import static google.registry.model.reporting.HistoryEntry.Type.DOMAIN_TRANSFER_REQUEST; import static google.registry.testing.DatastoreHelper.assertBillingEvents; import static google.registry.testing.DatastoreHelper.createTld; import static google.registry.testing.DatastoreHelper.getOnlyHistoryEntryOfType; @@ -67,6 +76,7 @@ import google.registry.model.poll.PendingActionNotificationResponse; import google.registry.model.poll.PollMessage; import google.registry.model.registry.Registry; import google.registry.model.registry.Registry.TldState; +import google.registry.model.reporting.DomainTransactionRecord; import google.registry.model.reporting.HistoryEntry; import google.registry.model.transfer.TransferData; import google.registry.model.transfer.TransferResponse; @@ -106,7 +116,7 @@ public class DomainDeleteFlowTest extends ResourceFlowTestCase substitutions) throws Exception { // Persist the billing event so it can be retrieved for cancellation generation and checking. - setupSuccessfulTest(); + setUpSuccessfulTest(); BillingEvent.OneTime graceBillingEvent = persistResource(createBillingEvent(Reason.CREATE, Money.of(USD, 123))); - setupGracePeriods(GracePeriod.forBillingEvent(gracePeriodStatus, graceBillingEvent)); + setUpGracePeriods(GracePeriod.forBillingEvent(gracePeriodStatus, graceBillingEvent)); // We should see exactly one poll message, which is for the autorenew 1 month in the future. assertPollMessages(createAutorenewPollMessage("TheRegistrar").build()); clock.advanceOneMilli(); @@ -258,8 +280,7 @@ public class DomainDeleteFlowTest extends ResourceFlowTestCase substitutions) throws Exception { // Persist the billing event so it can be retrieved for cancellation generation and checking. - setupSuccessfulTest(); + setUpSuccessfulTest(); BillingEvent.OneTime renewBillingEvent = persistResource(createBillingEvent(Reason.RENEW, Money.of(USD, 456))); - setupGracePeriods( + setUpGracePeriods( GracePeriod.forBillingEvent(GracePeriodStatus.RENEW, renewBillingEvent), // This grace period has no associated billing event, so it won't cause a cancellation. GracePeriod.create(GracePeriodStatus.TRANSFER, TIME_BEFORE_FLOW.plusDays(1), "foo", null)); @@ -322,9 +343,7 @@ public class DomainDeleteFlowTest extends ResourceFlowTestCaseof()); - setupSuccessfulTest(); + setUpSuccessfulTest(); // Persist the billing event so it can be retrieved for cancellation generation and checking. BillingEvent.OneTime graceBillingEvent = persistResource(createBillingEvent(Reason.CREATE, Money.of(USD, 123))); // Use a grace period so that the delete is immediate, simplifying the assertions below. - setupGracePeriods(GracePeriod.forBillingEvent(GracePeriodStatus.ADD, graceBillingEvent)); + setUpGracePeriods(GracePeriod.forBillingEvent(GracePeriodStatus.ADD, graceBillingEvent)); // Add a nameserver. HostResource host = persistResource(newHostResource("ns1.example.tld")); persistResource(loadByForeignKey( @@ -605,12 +620,12 @@ public class DomainDeleteFlowTest extends ResourceFlowTestCase