Create a scrap command to re-enable billing recurrences that were closed (#2077)

This is part of b/247839944 as a followup to the large bug from
September 2022. As a result of that, there are domains whose
BillingRecurrence objects were closed but the domain wasn't deleted. In
order to avoid having these domains stick around forever without being
billed, we want to restart billing on them whenever their next billing
cycle would have been.
This commit is contained in:
gbrodman 2023-07-14 16:38:17 -04:00 committed by GitHub
parent 7a386c4577
commit 3403399f38
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 291 additions and 0 deletions

View file

@ -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)

View file

@ -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.
*
* <p>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<String> mainParameters;
@Override
protected String prompt() throws Exception {
checkArgument(!mainParameters.isEmpty(), "Must provide at least one domain name");
return tm().transact(
() -> {
ImmutableList<BillingRecurrence> existingRecurrences = loadRecurrences();
ImmutableList<BillingRecurrence> 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<BillingRecurrence> newBillingRecurrences = tm().transact(this::internalExecute);
return "Created new recurrence(s): " + newBillingRecurrences;
}
private ImmutableList<BillingRecurrence> internalExecute() {
ImmutableList<BillingRecurrence> 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<BillingRecurrence> convertRecurrencesWithoutSaving(
ImmutableList<BillingRecurrence> 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<BillingRecurrence> loadRecurrences() {
ImmutableList.Builder<BillingRecurrence> 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<BillingRecurrence> 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();
}
}

View file

@ -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<RecreateBillingRecurrencesCommand> {
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");
}
}