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;
+ }
+}