From f985cfd7496bccd999b4d097fefe3f5619a44fb1 Mon Sep 17 00:00:00 2001 From: gbrodman Date: Mon, 17 Apr 2023 11:36:12 -0400 Subject: [PATCH] Add a command to update Recurrence objects' behavior (#1987) We want to basically be able to change the renewal behavior, either setting the behavior type (e.g. NONPREMIUM) or the specified renewal price. --- .../google/registry/tools/RegistryTool.java | 1 + .../tools/UpdateRecurrenceCommand.java | 195 +++++++++++++++ .../tools/UpdateRecurrenceCommandTest.java | 222 ++++++++++++++++++ 3 files changed, 418 insertions(+) create mode 100644 core/src/main/java/google/registry/tools/UpdateRecurrenceCommand.java create mode 100644 core/src/test/java/google/registry/tools/UpdateRecurrenceCommandTest.java diff --git a/core/src/main/java/google/registry/tools/RegistryTool.java b/core/src/main/java/google/registry/tools/RegistryTool.java index 464671870..423f2d67f 100644 --- a/core/src/main/java/google/registry/tools/RegistryTool.java +++ b/core/src/main/java/google/registry/tools/RegistryTool.java @@ -108,6 +108,7 @@ public final class RegistryTool { .put("update_keyring_secret", UpdateKeyringSecretCommand.class) .put("update_package_promotion", UpdatePackagePromotionCommand.class) .put("update_premium_list", UpdatePremiumListCommand.class) + .put("update_recurrence", UpdateRecurrenceCommand.class) .put("update_registrar", UpdateRegistrarCommand.class) .put("update_reserved_list", UpdateReservedListCommand.class) .put("update_server_locks", UpdateServerLocksCommand.class) diff --git a/core/src/main/java/google/registry/tools/UpdateRecurrenceCommand.java b/core/src/main/java/google/registry/tools/UpdateRecurrenceCommand.java new file mode 100644 index 000000000..d98693dc4 --- /dev/null +++ b/core/src/main/java/google/registry/tools/UpdateRecurrenceCommand.java @@ -0,0 +1,195 @@ +// Copyright 2023 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.tools; + +import static com.google.common.base.Preconditions.checkArgument; +import static google.registry.model.IdService.allocateId; +import static google.registry.persistence.transaction.TransactionManagerFactory.tm; +import static google.registry.util.DateTimeUtils.END_OF_TIME; + +import com.beust.jcommander.Parameter; +import com.beust.jcommander.Parameters; +import com.google.common.base.Joiner; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import google.registry.model.EppResourceUtils; +import google.registry.model.billing.BillingEvent.Recurring; +import google.registry.model.billing.BillingEvent.RenewalPriceBehavior; +import google.registry.model.domain.Domain; +import google.registry.model.domain.DomainHistory; +import google.registry.model.reporting.HistoryEntry; +import google.registry.model.reporting.HistoryEntry.HistoryEntryId; +import java.util.List; +import java.util.Optional; +import javax.annotation.Nullable; +import org.joda.money.Money; +import org.joda.time.DateTime; + +/** + * Command to update {@link Recurring} billing events with a new behavior and/or price. + * + *

More specifically, this closes the existing recurrence object and creates a new, similar, + * object as well as a corresponding synthetic {@link DomainHistory} object. This is done to + * preserve the recurrence's history. + */ +@Parameters(separators = " =", commandDescription = "Update a billing recurrence") +public class UpdateRecurrenceCommand extends ConfirmingCommand { + + private static final String HISTORY_REASON = + "Administrative update of billing recurrence behavior"; + + @Parameter( + description = "Domain name(s) for which we wish to update the recurrence(s)", + required = true) + private List mainParameters; + + @Nullable + @Parameter( + names = "--renewal_price_behavior", + description = "New RenewalPriceBehavior value to use with this recurrence") + RenewalPriceBehavior renewalPriceBehavior; + + @Nullable + @Parameter( + names = "--specified_renewal_price", + description = "Exact renewal price to use if the behavior is SPECIFIED") + Money specifiedRenewalPrice; + + @Override + protected String prompt() throws Exception { + checkArgument( + renewalPriceBehavior != null || specifiedRenewalPrice != null, + "Must specify a behavior and/or a price"); + + if (renewalPriceBehavior != null) { + if (renewalPriceBehavior.equals(RenewalPriceBehavior.SPECIFIED)) { + checkArgument( + specifiedRenewalPrice != null, + "Renewal price must be set when using SPECIFIED behavior"); + } else { + checkArgument( + specifiedRenewalPrice == null, + "Renewal price can have a value if and only if the renewal price behavior is" + + " SPECIFIED"); + } + } + ImmutableMap domainsAndRecurrings = + tm().transact(this::loadDomainsAndRecurrings); + if (renewalPriceBehavior == null) { + // Allow users to specify only a price only if all renewals are already SPECIFIED + domainsAndRecurrings.forEach( + (d, r) -> + checkArgument( + r.getRenewalPriceBehavior().equals(RenewalPriceBehavior.SPECIFIED), + "When specifying only a price, all domains must have SPECIFIED behavior. Domain" + + " %s does not", + d.getDomainName())); + } + String specifiedPriceString = + specifiedRenewalPrice == null ? "" : String.format(" and price %s", specifiedRenewalPrice); + return String.format( + "Update the following with behavior %s%s?\n%s", + renewalPriceBehavior, + specifiedPriceString, + Joiner.on('\n').withKeyValueSeparator(':').join(domainsAndRecurrings)); + } + + @Override + protected String execute() throws Exception { + ImmutableList newRecurrings = tm().transact(this::internalExecute); + return "Updated new recurring(s): " + newRecurrings; + } + + private ImmutableList internalExecute() { + ImmutableMap domainsAndRecurrings = loadDomainsAndRecurrings(); + DateTime now = tm().getTransactionTime(); + ImmutableList.Builder resultBuilder = new ImmutableList.Builder<>(); + domainsAndRecurrings.forEach( + (domain, existingRecurring) -> { + // Make a new history ID to break the (recurring, history, domain) circular dep chain + long newHistoryId = allocateId(); + HistoryEntryId newDomainHistoryId = new HistoryEntryId(domain.getRepoId(), newHistoryId); + Recurring endingNow = existingRecurring.asBuilder().setRecurrenceEndTime(now).build(); + Recurring.Builder newRecurringBuilder = + existingRecurring + .asBuilder() + // set the ID to be 0 (null) to create a new object + .setId(0) + .setDomainHistoryId(newDomainHistoryId); + if (renewalPriceBehavior != null) { + newRecurringBuilder.setRenewalPriceBehavior(renewalPriceBehavior); + newRecurringBuilder.setRenewalPrice(null); + } + if (specifiedRenewalPrice != null) { + newRecurringBuilder.setRenewalPrice(specifiedRenewalPrice); + } + Recurring newRecurring = newRecurringBuilder.build(); + Domain newDomain = + domain.asBuilder().setAutorenewBillingEvent(newRecurring.createVKey()).build(); + DomainHistory newDomainHistory = + new DomainHistory.Builder() + .setRevisionId(newDomainHistoryId.getRevisionId()) + .setDomain(newDomain) + .setReason(HISTORY_REASON) + .setRegistrarId(domain.getCurrentSponsorRegistrarId()) + .setBySuperuser(true) + .setRequestedByRegistrar(false) + .setType(HistoryEntry.Type.SYNTHETIC) + .setModificationTime(now) + .build(); + tm().putAll(endingNow, newRecurring, newDomain, newDomainHistory); + resultBuilder.add(newRecurring); + }); + return resultBuilder.build(); + } + + private ImmutableMap loadDomainsAndRecurrings() { + ImmutableMap.Builder result = new ImmutableMap.Builder<>(); + DateTime now = tm().getTransactionTime(); + for (String domainName : mainParameters) { + Domain domain = + EppResourceUtils.loadByForeignKey(Domain.class, domainName, now) + .orElseThrow( + () -> + new IllegalArgumentException( + String.format( + "Domain %s does not exist or has been deleted", domainName))); + checkArgument( + domain.getDeletionTime().equals(END_OF_TIME), + "Domain %s has already had a deletion time set", + domainName); + checkArgument( + domain.getTransferData().isEmpty(), + "Domain %s has a pending transfer: %s", + domainName, + domain.getTransferData()); + Optional domainAutorenewEndTime = domain.getAutorenewEndTime(); + domainAutorenewEndTime.ifPresent( + endTime -> + checkArgument( + endTime.isAfter(now), + "Domain %s autorenew ended prior to now at %s", + domainName, + endTime)); + Recurring recurring = tm().loadByKey(domain.getAutorenewBillingEvent()); + checkArgument( + recurring.getRecurrenceEndTime().equals(END_OF_TIME), + "Domain %s's recurrence's end date is not END_OF_TIME", + domainName); + result.put(domain, recurring); + } + return result.build(); + } +} diff --git a/core/src/test/java/google/registry/tools/UpdateRecurrenceCommandTest.java b/core/src/test/java/google/registry/tools/UpdateRecurrenceCommandTest.java new file mode 100644 index 000000000..93f9db562 --- /dev/null +++ b/core/src/test/java/google/registry/tools/UpdateRecurrenceCommandTest.java @@ -0,0 +1,222 @@ +// Copyright 2023 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.tools; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth8.assertThat; +import static google.registry.testing.DatabaseHelper.createTld; +import static google.registry.testing.DatabaseHelper.loadAllOf; +import static google.registry.testing.DatabaseHelper.loadByEntity; +import static google.registry.testing.DatabaseHelper.loadByKey; +import static google.registry.testing.DatabaseHelper.persistActiveContact; +import static google.registry.testing.DatabaseHelper.persistDomainWithDependentResources; +import static google.registry.testing.DatabaseHelper.persistDomainWithPendingTransfer; +import static google.registry.testing.DatabaseHelper.persistResource; +import static google.registry.util.DateTimeUtils.END_OF_TIME; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.common.collect.Iterables; +import google.registry.model.billing.BillingEvent.Reason; +import google.registry.model.billing.BillingEvent.Recurring; +import google.registry.model.billing.BillingEvent.RenewalPriceBehavior; +import google.registry.model.domain.Domain; +import google.registry.model.domain.DomainHistory; +import google.registry.model.reporting.HistoryEntry; +import java.util.Optional; +import javax.annotation.Nullable; +import org.joda.money.CurrencyUnit; +import org.joda.money.Money; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** Tests for {@link UpdateRecurrenceCommand}. */ +public class UpdateRecurrenceCommandTest extends CommandTestCase { + + @BeforeEach + void beforeEach() { + createTld("tld"); + } + + @Test + void testSuccess_setsSpecified() throws Exception { + persistDomain(); + Recurring existingRecurring = Iterables.getOnlyElement(loadAllOf(Recurring.class)); + assertThat(existingRecurring.getRecurrenceEndTime()).isEqualTo(END_OF_TIME); + assertThat(existingRecurring.getRenewalPriceBehavior()).isEqualTo(RenewalPriceBehavior.DEFAULT); + runCommandForced( + "domain.tld", + "--renewal_price_behavior", + "SPECIFIED", + "--specified_renewal_price", + "USD 9001"); + assertThat(loadByEntity(existingRecurring).getRecurrenceEndTime()) + .isEqualTo(fakeClock.nowUtc()); + assertNewBillingEventAndHistory( + existingRecurring.getId(), + RenewalPriceBehavior.SPECIFIED, + Money.of(CurrencyUnit.USD, 9001)); + } + + @Test + void testSuccess_setsNonPremium() throws Exception { + persistDomain(); + Recurring existingRecurring = Iterables.getOnlyElement(loadAllOf(Recurring.class)); + assertThat(existingRecurring.getRecurrenceEndTime()).isEqualTo(END_OF_TIME); + assertThat(existingRecurring.getRenewalPriceBehavior()).isEqualTo(RenewalPriceBehavior.DEFAULT); + runCommandForced("domain.tld", "--renewal_price_behavior", "NONPREMIUM"); + assertThat(loadByEntity(existingRecurring).getRecurrenceEndTime()) + .isEqualTo(fakeClock.nowUtc()); + assertNewBillingEventAndHistory( + existingRecurring.getId(), RenewalPriceBehavior.NONPREMIUM, null); + } + + @Test + void testSuccess_setsDefault() throws Exception { + persistDomain(); + Recurring existingRecurring = Iterables.getOnlyElement(loadAllOf(Recurring.class)); + persistResource( + existingRecurring + .asBuilder() + .setRenewalPriceBehavior(RenewalPriceBehavior.SPECIFIED) + .setRenewalPrice(Money.of(CurrencyUnit.USD, 100)) + .build()); + assertThat(existingRecurring.getRecurrenceEndTime()).isEqualTo(END_OF_TIME); + runCommandForced("domain.tld", "--renewal_price_behavior", "DEFAULT"); + assertThat(loadByEntity(existingRecurring).getRecurrenceEndTime()) + .isEqualTo(fakeClock.nowUtc()); + assertNewBillingEventAndHistory(existingRecurring.getId(), RenewalPriceBehavior.DEFAULT, null); + } + + @Test + void testSuccess_setsPrice_whenSpecifiedAlready() throws Exception { + Domain domain = persistDomain(); + Recurring recurring = loadByKey(domain.getAutorenewBillingEvent()); + persistResource( + recurring + .asBuilder() + .setRenewalPrice(Money.of(CurrencyUnit.USD, 20)) + .setRenewalPriceBehavior(RenewalPriceBehavior.SPECIFIED) + .build()); + runCommandForced("domain.tld", "--specified_renewal_price", "USD 9001"); + assertNewBillingEventAndHistory( + recurring.getId(), RenewalPriceBehavior.SPECIFIED, Money.of(CurrencyUnit.USD, 9001)); + } + + @Test + void testFailure_nonexistentDomain() { + assertThrows( + IllegalArgumentException.class, + () -> runCommandForced("nonexistent.tld", "--renewal_price_behavior", "NONPREMIUM")); + } + + @Test + void testFailure_invalidInputs() throws Exception { + persistDomain(); + assertThat(assertThrows(IllegalArgumentException.class, () -> runCommandForced("domain.tld"))) + .hasMessageThat() + .isEqualTo("Must specify a behavior and/or a price"); + command = newCommandInstance(); + assertThat( + assertThrows( + IllegalArgumentException.class, + () -> + runCommandForced( + "domain.tld", + "--renewal_price_behavior", + "DEFAULT", + "--specified_renewal_price", + "USD 50"))) + .hasMessageThat() + .isEqualTo( + "Renewal price can have a value if and only if the renewal price behavior is" + + " SPECIFIED"); + command = newCommandInstance(); + assertThat( + assertThrows( + IllegalArgumentException.class, + () -> runCommandForced("domain.tld", "--renewal_price_behavior", "SPECIFIED"))) + .hasMessageThat() + .isEqualTo("Renewal price must be set when using SPECIFIED behavior"); + command = newCommandInstance(); + assertThat( + assertThrows( + IllegalArgumentException.class, + () -> runCommandForced("domain.tld", "--specified_renewal_price", "USD 50"))) + .hasMessageThat() + .isEqualTo( + "When specifying only a price, all domains must have SPECIFIED behavior. Domain" + + " domain.tld does not"); + } + + @Test + void testFailure_billingAlreadyClosed() { + Domain domain = persistDomain(); + Recurring recurring = loadByKey(domain.getAutorenewBillingEvent()); + persistResource(recurring.asBuilder().setRecurrenceEndTime(fakeClock.nowUtc()).build()); + assertThat( + assertThrows( + IllegalArgumentException.class, + () -> runCommandForced("domain.tld", "--renewal_price_behavior", "NONPREMIUM"))) + .hasMessageThat() + .isEqualTo("Domain domain.tld's recurrence's end date is not END_OF_TIME"); + } + + @Test + void testFailure_pendingTransfer() { + persistDomainWithPendingTransfer( + persistDomain(), + fakeClock.nowUtc().minusMillis(1), + fakeClock.nowUtc().plusMonths(1), + END_OF_TIME); + assertThat( + assertThrows( + IllegalArgumentException.class, + () -> runCommandForced("domain.tld", "--renewal_price_behavior", "NONPREMIUM"))) + .hasMessageThat() + .startsWith("Domain domain.tld has a pending transfer: DomainTransferData: {"); + } + + private void assertNewBillingEventAndHistory( + long previousId, RenewalPriceBehavior expectedBehavior, @Nullable Money expectedPrice) { + Recurring newRecurring = + loadAllOf(Recurring.class).stream().filter(r -> r.getId() != previousId).findFirst().get(); + assertThat(newRecurring.getRecurrenceEndTime()).isEqualTo(END_OF_TIME); + assertThat(newRecurring.getRenewalPriceBehavior()).isEqualTo(expectedBehavior); + assertThat(newRecurring.getRenewalPrice()).isEqualTo(Optional.ofNullable(expectedPrice)); + assertThat(newRecurring.getReason()).isEqualTo(Reason.RENEW); + + DomainHistory newHistory = + loadAllOf(DomainHistory.class).stream() + .filter(dh -> !dh.getType().equals(HistoryEntry.Type.DOMAIN_CREATE)) + .findFirst() + .get(); + assertThat(newHistory.getType()).isEqualTo(HistoryEntry.Type.SYNTHETIC); + assertThat(newHistory.getReason()) + .isEqualTo("Administrative update of billing recurrence behavior"); + } + + private Domain persistDomain() { + Domain domain = + persistDomainWithDependentResources( + "domain", + "tld", + persistActiveContact("contact1234"), + fakeClock.nowUtc(), + fakeClock.nowUtc(), + END_OF_TIME); + fakeClock.advanceOneMilli(); + return domain; + } +}