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