diff --git a/core/src/main/java/google/registry/tools/RegistryTool.java b/core/src/main/java/google/registry/tools/RegistryTool.java
index 3bd783da2..53ffaf4ff 100644
--- a/core/src/main/java/google/registry/tools/RegistryTool.java
+++ b/core/src/main/java/google/registry/tools/RegistryTool.java
@@ -16,6 +16,7 @@ package google.registry.tools;
import com.google.common.collect.ImmutableMap;
import google.registry.tools.javascrap.CreateCancellationsForBillingEventsCommand;
+import google.registry.tools.javascrap.RecreateBillingRecurrencesCommand;
/** Container class to create and run remote commands against a server instance. */
public final class RegistryTool {
@@ -93,6 +94,7 @@ public final class RegistryTool {
.put("login", LoginCommand.class)
.put("logout", LogoutCommand.class)
.put("pending_escrow", PendingEscrowCommand.class)
+ .put("recreate_billing_recurrences", RecreateBillingRecurrencesCommand.class)
.put("registrar_poc", RegistrarPocCommand.class)
.put("renew_domain", RenewDomainCommand.class)
.put("save_sql_credential", SaveSqlCredentialCommand.class)
diff --git a/core/src/main/java/google/registry/tools/javascrap/RecreateBillingRecurrencesCommand.java b/core/src/main/java/google/registry/tools/javascrap/RecreateBillingRecurrencesCommand.java
new file mode 100644
index 000000000..64f49a353
--- /dev/null
+++ b/core/src/main/java/google/registry/tools/javascrap/RecreateBillingRecurrencesCommand.java
@@ -0,0 +1,146 @@
+// 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.javascrap;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+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 google.registry.model.EppResourceUtils;
+import google.registry.model.billing.BillingRecurrence;
+import google.registry.model.common.TimeOfYear;
+import google.registry.model.domain.Domain;
+import google.registry.persistence.VKey;
+import google.registry.persistence.transaction.QueryComposer.Comparator;
+import google.registry.tools.ConfirmingCommand;
+import java.util.List;
+import org.joda.time.DateTime;
+
+/**
+ * Command to recreate closed {@link BillingRecurrence}s for domains.
+ *
+ *
This can be used to fix situations where BillingRecurrences were inadvertently closed. The new
+ * recurrences will start at the recurrenceTimeOfYear that has most recently occurred in the past,
+ * so that billing will restart upon the next date that the domain would have normally been billed
+ * for autorenew.
+ */
+@Parameters(
+ separators = " =",
+ commandDescription = "Recreate inadvertently-closed BillingRecurrences.")
+public class RecreateBillingRecurrencesCommand extends ConfirmingCommand {
+
+ @Parameter(
+ description = "Domain name(s) for which we wish to recreate a BillingRecurrence",
+ required = true)
+ private List mainParameters;
+
+ @Override
+ protected String prompt() throws Exception {
+ checkArgument(!mainParameters.isEmpty(), "Must provide at least one domain name");
+ return tm().transact(
+ () -> {
+ ImmutableList existingRecurrences = loadRecurrences();
+ ImmutableList newRecurrences =
+ convertRecurrencesWithoutSaving(existingRecurrences);
+ return String.format(
+ "Create new BillingRecurrence(s)?\n"
+ + "Existing recurrences:\n"
+ + "%s\n"
+ + "New recurrences:\n"
+ + "%s",
+ Joiner.on('\n').join(existingRecurrences), Joiner.on('\n').join(newRecurrences));
+ });
+ }
+
+ @Override
+ protected String execute() throws Exception {
+ ImmutableList newBillingRecurrences = tm().transact(this::internalExecute);
+ return "Created new recurrence(s): " + newBillingRecurrences;
+ }
+
+ private ImmutableList internalExecute() {
+ ImmutableList newRecurrences =
+ convertRecurrencesWithoutSaving(loadRecurrences());
+ newRecurrences.forEach(
+ recurrence -> {
+ tm().put(recurrence);
+ Domain domain = tm().loadByKey(VKey.create(Domain.class, recurrence.getDomainRepoId()));
+ tm().put(domain.asBuilder().setAutorenewBillingEvent(recurrence.createVKey()).build());
+ });
+ return newRecurrences;
+ }
+
+ private ImmutableList convertRecurrencesWithoutSaving(
+ ImmutableList existingRecurrences) {
+ return existingRecurrences.stream()
+ .map(
+ existingRecurrence -> {
+ TimeOfYear timeOfYear = existingRecurrence.getRecurrenceTimeOfYear();
+ DateTime newLastExpansion =
+ timeOfYear.getLastInstanceBeforeOrAt(tm().getTransactionTime());
+ // event time should be the next date of billing in the future
+ DateTime eventTime = timeOfYear.getNextInstanceAtOrAfter(tm().getTransactionTime());
+ return existingRecurrence
+ .asBuilder()
+ .setRecurrenceEndTime(END_OF_TIME)
+ .setRecurrenceLastExpansion(newLastExpansion)
+ .setEventTime(eventTime)
+ .setId(0)
+ .build();
+ })
+ .collect(toImmutableList());
+ }
+
+ private ImmutableList loadRecurrences() {
+ ImmutableList.Builder result = new ImmutableList.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)));
+ BillingRecurrence billingRecurrence = tm().loadByKey(domain.getAutorenewBillingEvent());
+ checkArgument(
+ !billingRecurrence.getRecurrenceEndTime().equals(END_OF_TIME),
+ "Domain %s's recurrence's end date is already END_OF_TIME",
+ domainName);
+ // Double-check that there are no non-linked BillingRecurrences that have an END_OF_TIME end.
+ // If this is the case, something has been mis-linked.
+ ImmutableList allRecurrencesForDomain =
+ tm().createQueryComposer(BillingRecurrence.class)
+ .where("domainRepoId", Comparator.EQ, domain.getRepoId())
+ .list();
+ allRecurrencesForDomain.forEach(
+ recurrence ->
+ checkArgument(
+ !recurrence.getRecurrenceEndTime().equals(END_OF_TIME),
+ "There exists a recurrence with id %s for domain %s with an end date of"
+ + " END_OF_TIME",
+ recurrence.getId(),
+ domainName));
+
+ result.add(billingRecurrence);
+ }
+ return result.build();
+ }
+}
diff --git a/core/src/test/java/google/registry/tools/javascrap/RecreateBillingRecurrencesCommandTest.java b/core/src/test/java/google/registry/tools/javascrap/RecreateBillingRecurrencesCommandTest.java
new file mode 100644
index 000000000..6d0af47a7
--- /dev/null
+++ b/core/src/test/java/google/registry/tools/javascrap/RecreateBillingRecurrencesCommandTest.java
@@ -0,0 +1,143 @@
+// 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.javascrap;
+
+import static com.google.common.truth.Truth.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.persistResource;
+import static google.registry.util.DateTimeUtils.END_OF_TIME;
+import static org.junit.Assert.assertThrows;
+
+import google.registry.model.ImmutableObjectSubject;
+import google.registry.model.billing.BillingRecurrence;
+import google.registry.model.contact.Contact;
+import google.registry.model.domain.Domain;
+import google.registry.tools.CommandTestCase;
+import org.joda.time.DateTime;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/** Tests for {@link RecreateBillingRecurrencesCommand}. */
+public class RecreateBillingRecurrencesCommandTest
+ extends CommandTestCase {
+
+ private Contact contact;
+ private Domain domain;
+ private BillingRecurrence oldRecurrence;
+
+ @BeforeEach
+ void beforeEach() {
+ fakeClock.setTo(DateTime.parse("2022-09-05TZ"));
+ createTld("tld");
+ contact = persistActiveContact("contact1234");
+ domain =
+ persistDomainWithDependentResources(
+ "example",
+ "tld",
+ contact,
+ fakeClock.nowUtc(),
+ fakeClock.nowUtc(),
+ fakeClock.nowUtc().plusYears(1));
+ oldRecurrence = loadByKey(domain.getAutorenewBillingEvent());
+ oldRecurrence =
+ persistResource(
+ oldRecurrence.asBuilder().setRecurrenceEndTime(fakeClock.nowUtc().plusDays(1)).build());
+ fakeClock.setTo(DateTime.parse("2023-07-11TZ"));
+ }
+
+ @Test
+ void testSuccess_simpleRecreation() throws Exception {
+ runCommandForced("example.tld");
+ // The domain should now be linked to the new recurrence
+ BillingRecurrence newRecurrence = loadByKey(loadByEntity(domain).getAutorenewBillingEvent());
+ assertThat(newRecurrence.getId()).isNotEqualTo(oldRecurrence.getId());
+ // The new recurrence should not end and have last year's event time and last expansion.
+ assertThat(newRecurrence.getRecurrenceEndTime()).isEqualTo(END_OF_TIME);
+ assertThat(newRecurrence.getEventTime()).isEqualTo(DateTime.parse("2023-09-05TZ"));
+ assertThat(newRecurrence.getRecurrenceLastExpansion())
+ .isEqualTo(DateTime.parse("2022-09-05TZ"));
+ assertThat(loadAllOf(BillingRecurrence.class)).containsExactly(oldRecurrence, newRecurrence);
+ }
+
+ @Test
+ void testSuccess_multipleDomains() throws Exception {
+ Domain otherDomain =
+ persistDomainWithDependentResources(
+ "other",
+ "tld",
+ contact,
+ DateTime.parse("2022-09-07TZ"),
+ DateTime.parse("2022-09-07TZ"),
+ DateTime.parse("2023-09-07TZ"));
+ BillingRecurrence otherRecurrence = loadByKey(otherDomain.getAutorenewBillingEvent());
+ otherRecurrence =
+ persistResource(
+ otherRecurrence
+ .asBuilder()
+ .setRecurrenceEndTime(DateTime.parse("2022-09-08TZ"))
+ .build());
+ runCommandForced("example.tld", "other.tld");
+ // Both domains should have new recurrences with END_OF_TIME expirations
+ BillingRecurrence otherNewRecurrence =
+ loadByKey(loadByEntity(otherDomain).getAutorenewBillingEvent());
+ assertThat(otherNewRecurrence.getId()).isNotEqualTo(otherRecurrence.getId());
+ assertThat(otherNewRecurrence.getRecurrenceEndTime()).isEqualTo(END_OF_TIME);
+ assertThat(otherNewRecurrence.getEventTime()).isEqualTo(DateTime.parse("2023-09-07TZ"));
+ assertThat(otherNewRecurrence.getRecurrenceLastExpansion())
+ .isEqualTo(DateTime.parse("2022-09-07TZ"));
+ assertThat(loadAllOf(BillingRecurrence.class))
+ .comparingElementsUsing(ImmutableObjectSubject.immutableObjectCorrespondence("id"))
+ .containsExactly(
+ oldRecurrence,
+ oldRecurrence
+ .asBuilder()
+ .setRecurrenceEndTime(END_OF_TIME)
+ .setEventTime(DateTime.parse("2023-09-05TZ"))
+ .setRecurrenceLastExpansion(DateTime.parse("2022-09-05TZ"))
+ .build(),
+ otherRecurrence,
+ otherNewRecurrence);
+ }
+
+ @Test
+ void testFailure_badDomain() {
+ assertThat(assertThrows(IllegalArgumentException.class, () -> runCommandForced("foo.tld")))
+ .hasMessageThat()
+ .isEqualTo("Domain foo.tld does not exist or has been deleted");
+ }
+
+ @Test
+ void testFailure_alreadyEndOfTime() {
+ persistResource(oldRecurrence.asBuilder().setRecurrenceEndTime(END_OF_TIME).build());
+ assertThat(assertThrows(IllegalArgumentException.class, () -> runCommandForced("example.tld")))
+ .hasMessageThat()
+ .isEqualTo("Domain example.tld's recurrence's end date is already END_OF_TIME");
+ }
+
+ @Test
+ void testFailure_nonLinkedRecurrenceIsEndOfTime() {
+ persistResource(oldRecurrence.asBuilder().setRecurrenceEndTime(END_OF_TIME).setId(0).build());
+ assertThat(assertThrows(IllegalArgumentException.class, () -> runCommandForced("example.tld")))
+ .hasMessageThat()
+ .isEqualTo(
+ "There exists a recurrence with id 9 for domain example.tld with an end date of"
+ + " END_OF_TIME");
+ }
+}