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