Skip to content

Commit

Permalink
Create a scrap command to cancel OneTime billing events by ID (#1790)
Browse files Browse the repository at this point in the history
This allows us to correct situations where we have erroneously charged
registrars for an action, without explicitly issuing a refund.
  • Loading branch information
gbrodman committed Sep 16, 2022
1 parent edbca15 commit 372c854
Show file tree
Hide file tree
Showing 4 changed files with 282 additions and 0 deletions.
2 changes: 2 additions & 0 deletions core/src/main/java/google/registry/tools/RegistryTool.java
Expand Up @@ -16,6 +16,7 @@

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 {
Expand All @@ -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)
Expand Down
Expand Up @@ -42,6 +42,7 @@
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;
Expand Down Expand Up @@ -95,6 +96,8 @@ interface RegistryToolComponent {

void inject(CreateAnchorTenantCommand command);

void inject(CreateCancellationsForOneTimesCommand command);

void inject(CreateCdnsTld command);

void inject(CreateContactCommand command);
Expand Down
@@ -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.
*
* <p>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<Long> mainParameters;

private ImmutableSet<OneTime> oneTimesToCancel;

@Override
protected void init() {
ImmutableSet.Builder<Long> missingIdsBuilder = new ImmutableSet.Builder<>();
ImmutableSet.Builder<Long> alreadyCancelledIdsBuilder = new ImmutableSet.Builder<>();
ImmutableSet.Builder<OneTime> oneTimesBuilder = new ImmutableSet.Builder<>();
tm().transact(
() -> {
for (Long billingEventId : ImmutableSet.copyOf(mainParameters)) {
VKey<OneTime> 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<Long> missingIds = missingIdsBuilder.build();
if (!missingIds.isEmpty()) {
System.out.printf("Missing OneTime(s) for IDs %s\n", missingIds);
}
ImmutableSet<Long> 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();
}
}
@@ -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<CreateCancellationsForOneTimesCommand> {

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

0 comments on commit 372c854

Please sign in to comment.