Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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.
- Loading branch information
Showing
4 changed files
with
282 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
126 changes: 126 additions & 0 deletions
126
.../src/main/java/google/registry/tools/javascrap/CreateCancellationsForOneTimesCommand.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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(); | ||
} | ||
} |
151 changes: 151 additions & 0 deletions
151
.../test/java/google/registry/tools/javascrap/CreateCancellationsForOneTimesCommandTest.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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(); | ||
} | ||
} |