From 1e2e0127c49b1e05564704a92bb90c7a3ef9e22c Mon Sep 17 00:00:00 2001 From: gbrodman Date: Fri, 16 Sep 2022 16:17:31 -0400 Subject: [PATCH] Create a scrap command to cancel OneTime billing events by ID (#1790) This allows us to correct situations where we have erroneously charged registrars for an action, without explicitly issuing a refund. --- .../google/registry/tools/RegistryTool.java | 2 + .../registry/tools/RegistryToolComponent.java | 3 + ...CreateCancellationsForOneTimesCommand.java | 126 +++++++++++++++ ...teCancellationsForOneTimesCommandTest.java | 151 ++++++++++++++++++ 4 files changed, 282 insertions(+) create mode 100644 core/src/main/java/google/registry/tools/javascrap/CreateCancellationsForOneTimesCommand.java create mode 100644 core/src/test/java/google/registry/tools/javascrap/CreateCancellationsForOneTimesCommandTest.java diff --git a/core/src/main/java/google/registry/tools/RegistryTool.java b/core/src/main/java/google/registry/tools/RegistryTool.java index b8160c10c..c1475d9b1 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.CompareEscrowDepositsCommand; +import google.registry.tools.javascrap.CreateCancellationsForOneTimesCommand; /** Container class to create and run remote commands against a Datastore instance. */ public final class RegistryTool { @@ -36,6 +37,7 @@ public final class RegistryTool { .put("convert_idn", ConvertIdnCommand.class) .put("count_domains", CountDomainsCommand.class) .put("create_anchor_tenant", CreateAnchorTenantCommand.class) + .put("create_cancellations_for_one_times", CreateCancellationsForOneTimesCommand.class) .put("create_cdns_tld", CreateCdnsTld.class) .put("create_contact", CreateContactCommand.class) .put("create_domain", CreateDomainCommand.class) diff --git a/core/src/main/java/google/registry/tools/RegistryToolComponent.java b/core/src/main/java/google/registry/tools/RegistryToolComponent.java index e3ed485d2..ec82ff2f8 100644 --- a/core/src/main/java/google/registry/tools/RegistryToolComponent.java +++ b/core/src/main/java/google/registry/tools/RegistryToolComponent.java @@ -42,6 +42,7 @@ import google.registry.request.Modules.UrlFetchServiceModule; import google.registry.request.Modules.UserServiceModule; import google.registry.tools.AuthModule.LocalCredentialModule; import google.registry.tools.javascrap.CompareEscrowDepositsCommand; +import google.registry.tools.javascrap.CreateCancellationsForOneTimesCommand; import google.registry.util.UtilsModule; import google.registry.whois.NonCachingWhoisModule; import javax.annotation.Nullable; @@ -95,6 +96,8 @@ interface RegistryToolComponent { void inject(CreateAnchorTenantCommand command); + void inject(CreateCancellationsForOneTimesCommand command); + void inject(CreateCdnsTld command); void inject(CreateContactCommand command); diff --git a/core/src/main/java/google/registry/tools/javascrap/CreateCancellationsForOneTimesCommand.java b/core/src/main/java/google/registry/tools/javascrap/CreateCancellationsForOneTimesCommand.java new file mode 100644 index 000000000..1a7df9b72 --- /dev/null +++ b/core/src/main/java/google/registry/tools/javascrap/CreateCancellationsForOneTimesCommand.java @@ -0,0 +1,126 @@ +// Copyright 2022 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 google.registry.persistence.transaction.TransactionManagerFactory.tm; + +import com.beust.jcommander.Parameter; +import com.beust.jcommander.Parameters; +import com.google.common.collect.ImmutableSet; +import google.registry.model.billing.BillingEvent; +import google.registry.model.billing.BillingEvent.Cancellation; +import google.registry.model.billing.BillingEvent.OneTime; +import google.registry.persistence.VKey; +import google.registry.persistence.transaction.QueryComposer.Comparator; +import google.registry.tools.CommandWithRemoteApi; +import google.registry.tools.ConfirmingCommand; +import google.registry.tools.params.LongParameter; +import java.util.List; + +/** + * Command to create {@link Cancellation}s for specified {@link OneTime} billing events. + * + *

This can be used to fix situations where we've inadvertently billed registrars. It's generally + * easier and better to issue cancellations within the Nomulus system than to attempt to issue + * refunds after the fact. + */ +@Parameters(separators = " =", commandDescription = "Manually create Cancellations for OneTimes.") +public class CreateCancellationsForOneTimesCommand extends ConfirmingCommand + implements CommandWithRemoteApi { + + @Parameter( + description = "Space-delimited billing event ID(s) to cancel", + required = true, + validateWith = LongParameter.class) + private List mainParameters; + + private ImmutableSet oneTimesToCancel; + + @Override + protected void init() { + ImmutableSet.Builder missingIdsBuilder = new ImmutableSet.Builder<>(); + ImmutableSet.Builder alreadyCancelledIdsBuilder = new ImmutableSet.Builder<>(); + ImmutableSet.Builder oneTimesBuilder = new ImmutableSet.Builder<>(); + tm().transact( + () -> { + for (Long billingEventId : ImmutableSet.copyOf(mainParameters)) { + VKey key = VKey.createSql(OneTime.class, billingEventId); + if (tm().exists(key)) { + OneTime oneTime = tm().loadByKey(key); + if (alreadyCancelled(oneTime)) { + alreadyCancelledIdsBuilder.add(billingEventId); + } else { + oneTimesBuilder.add(oneTime); + } + } else { + missingIdsBuilder.add(billingEventId); + } + } + }); + oneTimesToCancel = oneTimesBuilder.build(); + System.out.printf("Found %d OneTime(s) to cancel\n", oneTimesToCancel.size()); + ImmutableSet missingIds = missingIdsBuilder.build(); + if (!missingIds.isEmpty()) { + System.out.printf("Missing OneTime(s) for IDs %s\n", missingIds); + } + ImmutableSet alreadyCancelledIds = alreadyCancelledIdsBuilder.build(); + if (!alreadyCancelledIds.isEmpty()) { + System.out.printf( + "The following OneTime IDs were already cancelled: %s\n", alreadyCancelledIds); + } + } + + @Override + protected String prompt() { + return String.format("Create cancellations for %d OneTime(s)?", oneTimesToCancel.size()); + } + + @Override + protected String execute() throws Exception { + int cancelledOneTimes = 0; + for (OneTime oneTime : oneTimesToCancel) { + cancelledOneTimes += + tm().transact( + () -> { + if (alreadyCancelled(oneTime)) { + System.out.printf( + "OneTime %d already cancelled, this is unexpected.\n", oneTime.getId()); + return 0; + } + tm().put( + new Cancellation.Builder() + .setOneTimeEventKey(oneTime.createVKey()) + .setBillingTime(oneTime.getBillingTime()) + .setDomainHistoryId(oneTime.getDomainHistoryId()) + .setRegistrarId(oneTime.getRegistrarId()) + .setEventTime(oneTime.getEventTime()) + .setReason(BillingEvent.Reason.ERROR) + .setTargetId(oneTime.getTargetId()) + .build()); + System.out.printf( + "Added Cancellation for OneTime with ID %d\n", oneTime.getId()); + return 1; + }); + } + return String.format("Created %d Cancellation event(s)", cancelledOneTimes); + } + + private boolean alreadyCancelled(OneTime oneTime) { + return tm().createQueryComposer(Cancellation.class) + .where("refOneTime", Comparator.EQ, oneTime.getId()) + .first() + .isPresent(); + } +} diff --git a/core/src/test/java/google/registry/tools/javascrap/CreateCancellationsForOneTimesCommandTest.java b/core/src/test/java/google/registry/tools/javascrap/CreateCancellationsForOneTimesCommandTest.java new file mode 100644 index 000000000..56ffbd26d --- /dev/null +++ b/core/src/test/java/google/registry/tools/javascrap/CreateCancellationsForOneTimesCommandTest.java @@ -0,0 +1,151 @@ +// Copyright 2022 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.persistActiveContact; +import static google.registry.testing.DatabaseHelper.persistDomainWithDependentResources; +import static google.registry.testing.DatabaseHelper.persistResource; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.beust.jcommander.ParameterException; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Iterables; +import google.registry.model.billing.BillingEvent.Cancellation; +import google.registry.model.billing.BillingEvent.OneTime; +import google.registry.model.billing.BillingEvent.Reason; +import google.registry.model.contact.Contact; +import google.registry.model.domain.Domain; +import google.registry.model.domain.DomainHistory; +import google.registry.model.reporting.HistoryEntryDao; +import google.registry.persistence.VKey; +import google.registry.testing.DatabaseHelper; +import google.registry.tools.CommandTestCase; +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 CreateCancellationsForOneTimesCommand}. */ +public class CreateCancellationsForOneTimesCommandTest + extends CommandTestCase { + + private Domain domain; + private OneTime oneTimeToCancel; + + @BeforeEach + void beforeEach() { + createTld("tld"); + Contact contact = persistActiveContact("contact1234"); + domain = + persistDomainWithDependentResources( + "example", + "tld", + contact, + fakeClock.nowUtc(), + fakeClock.nowUtc(), + fakeClock.nowUtc().plusYears(2)); + oneTimeToCancel = createOneTime(); + } + + @Test + void testSimpleDelete() throws Exception { + assertThat(DatabaseHelper.loadAllOf(Cancellation.class)).isEmpty(); + runCommandForced(String.valueOf(oneTimeToCancel.getId())); + assertBillingEventCancelled(); + assertInStdout("Added Cancellation for OneTime with ID 9"); + assertInStdout("Created 1 Cancellation event(s)"); + } + + @Test + void testSuccess_oneExistsOneDoesnt() throws Exception { + runCommandForced(String.valueOf(oneTimeToCancel.getId()), "9001"); + assertBillingEventCancelled(); + assertInStdout("Found 1 OneTime(s) to cancel"); + assertInStdout("Missing OneTime(s) for IDs [9001]"); + assertInStdout("Added Cancellation for OneTime with ID 9"); + assertInStdout("Created 1 Cancellation event(s)"); + } + + @Test + void testSuccess_multipleCancellations() throws Exception { + OneTime secondToCancel = createOneTime(); + assertThat(DatabaseHelper.loadAllOf(Cancellation.class)).isEmpty(); + runCommandForced( + String.valueOf(oneTimeToCancel.getId()), String.valueOf(secondToCancel.getId())); + assertBillingEventCancelled(oneTimeToCancel.getId()); + assertBillingEventCancelled(secondToCancel.getId()); + assertInStdout("Create cancellations for 2 OneTime(s)?"); + assertInStdout("Added Cancellation for OneTime with ID 9"); + assertInStdout("Added Cancellation for OneTime with ID 10"); + assertInStdout("Created 2 Cancellation event(s)"); + } + + @Test + void testAlreadyCancelled() throws Exception { + // multiple runs / cancellations should be a no-op + runCommandForced(String.valueOf(oneTimeToCancel.getId())); + assertBillingEventCancelled(); + runCommandForced(String.valueOf(oneTimeToCancel.getId())); + assertBillingEventCancelled(); + assertThat(DatabaseHelper.loadAllOf(Cancellation.class)).hasSize(1); + assertInStdout("Found 0 OneTime(s) to cancel"); + assertInStdout("The following OneTime IDs were already cancelled: [9]"); + } + + @Test + void testFailure_doesntExist() throws Exception { + runCommandForced("9001"); + assertThat(DatabaseHelper.loadAllOf(Cancellation.class)).isEmpty(); + assertInStdout("Found 0 OneTime(s) to cancel"); + assertInStdout("Missing OneTime(s) for IDs [9001]"); + assertInStdout("Created 0 Cancellation event(s)"); + } + + @Test + void testFailure_noIds() { + assertThrows(ParameterException.class, this::runCommandForced); + } + + private OneTime createOneTime() { + return persistResource( + new OneTime.Builder() + .setReason(Reason.CREATE) + .setTargetId(domain.getDomainName()) + .setRegistrarId("TheRegistrar") + .setCost(Money.of(CurrencyUnit.USD, 10)) + .setPeriodYears(2) + .setEventTime(fakeClock.nowUtc()) + .setBillingTime(fakeClock.nowUtc()) + .setFlags(ImmutableSet.of()) + .setDomainHistory( + Iterables.getOnlyElement( + HistoryEntryDao.loadHistoryObjectsForResource( + domain.createVKey(), DomainHistory.class))) + .build()); + } + + private void assertBillingEventCancelled() { + assertBillingEventCancelled(oneTimeToCancel.getId()); + } + + private void assertBillingEventCancelled(long oneTimeId) { + assertThat( + DatabaseHelper.loadAllOf(Cancellation.class).stream() + .anyMatch(c -> c.getEventKey().equals(VKey.createSql(OneTime.class, oneTimeId)))) + .isTrue(); + } +}