mirror of
https://github.com/google/nomulus.git
synced 2025-04-30 03:57:51 +02:00
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.
This commit is contained in:
parent
561be028c4
commit
1e2e0127c4
4 changed files with 282 additions and 0 deletions
|
@ -16,6 +16,7 @@ package google.registry.tools;
|
||||||
|
|
||||||
import com.google.common.collect.ImmutableMap;
|
import com.google.common.collect.ImmutableMap;
|
||||||
import google.registry.tools.javascrap.CompareEscrowDepositsCommand;
|
import google.registry.tools.javascrap.CompareEscrowDepositsCommand;
|
||||||
|
import google.registry.tools.javascrap.CreateCancellationsForOneTimesCommand;
|
||||||
|
|
||||||
/** Container class to create and run remote commands against a Datastore instance. */
|
/** Container class to create and run remote commands against a Datastore instance. */
|
||||||
public final class RegistryTool {
|
public final class RegistryTool {
|
||||||
|
@ -36,6 +37,7 @@ public final class RegistryTool {
|
||||||
.put("convert_idn", ConvertIdnCommand.class)
|
.put("convert_idn", ConvertIdnCommand.class)
|
||||||
.put("count_domains", CountDomainsCommand.class)
|
.put("count_domains", CountDomainsCommand.class)
|
||||||
.put("create_anchor_tenant", CreateAnchorTenantCommand.class)
|
.put("create_anchor_tenant", CreateAnchorTenantCommand.class)
|
||||||
|
.put("create_cancellations_for_one_times", CreateCancellationsForOneTimesCommand.class)
|
||||||
.put("create_cdns_tld", CreateCdnsTld.class)
|
.put("create_cdns_tld", CreateCdnsTld.class)
|
||||||
.put("create_contact", CreateContactCommand.class)
|
.put("create_contact", CreateContactCommand.class)
|
||||||
.put("create_domain", CreateDomainCommand.class)
|
.put("create_domain", CreateDomainCommand.class)
|
||||||
|
|
|
@ -42,6 +42,7 @@ import google.registry.request.Modules.UrlFetchServiceModule;
|
||||||
import google.registry.request.Modules.UserServiceModule;
|
import google.registry.request.Modules.UserServiceModule;
|
||||||
import google.registry.tools.AuthModule.LocalCredentialModule;
|
import google.registry.tools.AuthModule.LocalCredentialModule;
|
||||||
import google.registry.tools.javascrap.CompareEscrowDepositsCommand;
|
import google.registry.tools.javascrap.CompareEscrowDepositsCommand;
|
||||||
|
import google.registry.tools.javascrap.CreateCancellationsForOneTimesCommand;
|
||||||
import google.registry.util.UtilsModule;
|
import google.registry.util.UtilsModule;
|
||||||
import google.registry.whois.NonCachingWhoisModule;
|
import google.registry.whois.NonCachingWhoisModule;
|
||||||
import javax.annotation.Nullable;
|
import javax.annotation.Nullable;
|
||||||
|
@ -95,6 +96,8 @@ interface RegistryToolComponent {
|
||||||
|
|
||||||
void inject(CreateAnchorTenantCommand command);
|
void inject(CreateAnchorTenantCommand command);
|
||||||
|
|
||||||
|
void inject(CreateCancellationsForOneTimesCommand command);
|
||||||
|
|
||||||
void inject(CreateCdnsTld command);
|
void inject(CreateCdnsTld command);
|
||||||
|
|
||||||
void inject(CreateContactCommand command);
|
void inject(CreateContactCommand command);
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue