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