Add a command to update Recurrence objects' behavior (#1987)

We want to basically be able to change the renewal behavior, either
setting the behavior type (e.g. NONPREMIUM) or the specified renewal
price.
This commit is contained in:
gbrodman 2023-04-17 11:36:12 -04:00 committed by GitHub
parent dcc7e65813
commit f985cfd749
3 changed files with 418 additions and 0 deletions

View file

@ -108,6 +108,7 @@ public final class RegistryTool {
.put("update_keyring_secret", UpdateKeyringSecretCommand.class) .put("update_keyring_secret", UpdateKeyringSecretCommand.class)
.put("update_package_promotion", UpdatePackagePromotionCommand.class) .put("update_package_promotion", UpdatePackagePromotionCommand.class)
.put("update_premium_list", UpdatePremiumListCommand.class) .put("update_premium_list", UpdatePremiumListCommand.class)
.put("update_recurrence", UpdateRecurrenceCommand.class)
.put("update_registrar", UpdateRegistrarCommand.class) .put("update_registrar", UpdateRegistrarCommand.class)
.put("update_reserved_list", UpdateReservedListCommand.class) .put("update_reserved_list", UpdateReservedListCommand.class)
.put("update_server_locks", UpdateServerLocksCommand.class) .put("update_server_locks", UpdateServerLocksCommand.class)

View file

@ -0,0 +1,195 @@
// Copyright 2023 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;
import static com.google.common.base.Preconditions.checkArgument;
import static google.registry.model.IdService.allocateId;
import static google.registry.persistence.transaction.TransactionManagerFactory.tm;
import static google.registry.util.DateTimeUtils.END_OF_TIME;
import com.beust.jcommander.Parameter;
import com.beust.jcommander.Parameters;
import com.google.common.base.Joiner;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import google.registry.model.EppResourceUtils;
import google.registry.model.billing.BillingEvent.Recurring;
import google.registry.model.billing.BillingEvent.RenewalPriceBehavior;
import google.registry.model.domain.Domain;
import google.registry.model.domain.DomainHistory;
import google.registry.model.reporting.HistoryEntry;
import google.registry.model.reporting.HistoryEntry.HistoryEntryId;
import java.util.List;
import java.util.Optional;
import javax.annotation.Nullable;
import org.joda.money.Money;
import org.joda.time.DateTime;
/**
* Command to update {@link Recurring} billing events with a new behavior and/or price.
*
* <p>More specifically, this closes the existing recurrence object and creates a new, similar,
* object as well as a corresponding synthetic {@link DomainHistory} object. This is done to
* preserve the recurrence's history.
*/
@Parameters(separators = " =", commandDescription = "Update a billing recurrence")
public class UpdateRecurrenceCommand extends ConfirmingCommand {
private static final String HISTORY_REASON =
"Administrative update of billing recurrence behavior";
@Parameter(
description = "Domain name(s) for which we wish to update the recurrence(s)",
required = true)
private List<String> mainParameters;
@Nullable
@Parameter(
names = "--renewal_price_behavior",
description = "New RenewalPriceBehavior value to use with this recurrence")
RenewalPriceBehavior renewalPriceBehavior;
@Nullable
@Parameter(
names = "--specified_renewal_price",
description = "Exact renewal price to use if the behavior is SPECIFIED")
Money specifiedRenewalPrice;
@Override
protected String prompt() throws Exception {
checkArgument(
renewalPriceBehavior != null || specifiedRenewalPrice != null,
"Must specify a behavior and/or a price");
if (renewalPriceBehavior != null) {
if (renewalPriceBehavior.equals(RenewalPriceBehavior.SPECIFIED)) {
checkArgument(
specifiedRenewalPrice != null,
"Renewal price must be set when using SPECIFIED behavior");
} else {
checkArgument(
specifiedRenewalPrice == null,
"Renewal price can have a value if and only if the renewal price behavior is"
+ " SPECIFIED");
}
}
ImmutableMap<Domain, Recurring> domainsAndRecurrings =
tm().transact(this::loadDomainsAndRecurrings);
if (renewalPriceBehavior == null) {
// Allow users to specify only a price only if all renewals are already SPECIFIED
domainsAndRecurrings.forEach(
(d, r) ->
checkArgument(
r.getRenewalPriceBehavior().equals(RenewalPriceBehavior.SPECIFIED),
"When specifying only a price, all domains must have SPECIFIED behavior. Domain"
+ " %s does not",
d.getDomainName()));
}
String specifiedPriceString =
specifiedRenewalPrice == null ? "" : String.format(" and price %s", specifiedRenewalPrice);
return String.format(
"Update the following with behavior %s%s?\n%s",
renewalPriceBehavior,
specifiedPriceString,
Joiner.on('\n').withKeyValueSeparator(':').join(domainsAndRecurrings));
}
@Override
protected String execute() throws Exception {
ImmutableList<Recurring> newRecurrings = tm().transact(this::internalExecute);
return "Updated new recurring(s): " + newRecurrings;
}
private ImmutableList<Recurring> internalExecute() {
ImmutableMap<Domain, Recurring> domainsAndRecurrings = loadDomainsAndRecurrings();
DateTime now = tm().getTransactionTime();
ImmutableList.Builder<Recurring> resultBuilder = new ImmutableList.Builder<>();
domainsAndRecurrings.forEach(
(domain, existingRecurring) -> {
// Make a new history ID to break the (recurring, history, domain) circular dep chain
long newHistoryId = allocateId();
HistoryEntryId newDomainHistoryId = new HistoryEntryId(domain.getRepoId(), newHistoryId);
Recurring endingNow = existingRecurring.asBuilder().setRecurrenceEndTime(now).build();
Recurring.Builder newRecurringBuilder =
existingRecurring
.asBuilder()
// set the ID to be 0 (null) to create a new object
.setId(0)
.setDomainHistoryId(newDomainHistoryId);
if (renewalPriceBehavior != null) {
newRecurringBuilder.setRenewalPriceBehavior(renewalPriceBehavior);
newRecurringBuilder.setRenewalPrice(null);
}
if (specifiedRenewalPrice != null) {
newRecurringBuilder.setRenewalPrice(specifiedRenewalPrice);
}
Recurring newRecurring = newRecurringBuilder.build();
Domain newDomain =
domain.asBuilder().setAutorenewBillingEvent(newRecurring.createVKey()).build();
DomainHistory newDomainHistory =
new DomainHistory.Builder()
.setRevisionId(newDomainHistoryId.getRevisionId())
.setDomain(newDomain)
.setReason(HISTORY_REASON)
.setRegistrarId(domain.getCurrentSponsorRegistrarId())
.setBySuperuser(true)
.setRequestedByRegistrar(false)
.setType(HistoryEntry.Type.SYNTHETIC)
.setModificationTime(now)
.build();
tm().putAll(endingNow, newRecurring, newDomain, newDomainHistory);
resultBuilder.add(newRecurring);
});
return resultBuilder.build();
}
private ImmutableMap<Domain, Recurring> loadDomainsAndRecurrings() {
ImmutableMap.Builder<Domain, Recurring> result = new ImmutableMap.Builder<>();
DateTime now = tm().getTransactionTime();
for (String domainName : mainParameters) {
Domain domain =
EppResourceUtils.loadByForeignKey(Domain.class, domainName, now)
.orElseThrow(
() ->
new IllegalArgumentException(
String.format(
"Domain %s does not exist or has been deleted", domainName)));
checkArgument(
domain.getDeletionTime().equals(END_OF_TIME),
"Domain %s has already had a deletion time set",
domainName);
checkArgument(
domain.getTransferData().isEmpty(),
"Domain %s has a pending transfer: %s",
domainName,
domain.getTransferData());
Optional<DateTime> domainAutorenewEndTime = domain.getAutorenewEndTime();
domainAutorenewEndTime.ifPresent(
endTime ->
checkArgument(
endTime.isAfter(now),
"Domain %s autorenew ended prior to now at %s",
domainName,
endTime));
Recurring recurring = tm().loadByKey(domain.getAutorenewBillingEvent());
checkArgument(
recurring.getRecurrenceEndTime().equals(END_OF_TIME),
"Domain %s's recurrence's end date is not END_OF_TIME",
domainName);
result.put(domain, recurring);
}
return result.build();
}
}

View file

@ -0,0 +1,222 @@
// Copyright 2023 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;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth8.assertThat;
import static google.registry.testing.DatabaseHelper.createTld;
import static google.registry.testing.DatabaseHelper.loadAllOf;
import static google.registry.testing.DatabaseHelper.loadByEntity;
import static google.registry.testing.DatabaseHelper.loadByKey;
import static google.registry.testing.DatabaseHelper.persistActiveContact;
import static google.registry.testing.DatabaseHelper.persistDomainWithDependentResources;
import static google.registry.testing.DatabaseHelper.persistDomainWithPendingTransfer;
import static google.registry.testing.DatabaseHelper.persistResource;
import static google.registry.util.DateTimeUtils.END_OF_TIME;
import static org.junit.jupiter.api.Assertions.assertThrows;
import com.google.common.collect.Iterables;
import google.registry.model.billing.BillingEvent.Reason;
import google.registry.model.billing.BillingEvent.Recurring;
import google.registry.model.billing.BillingEvent.RenewalPriceBehavior;
import google.registry.model.domain.Domain;
import google.registry.model.domain.DomainHistory;
import google.registry.model.reporting.HistoryEntry;
import java.util.Optional;
import javax.annotation.Nullable;
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 UpdateRecurrenceCommand}. */
public class UpdateRecurrenceCommandTest extends CommandTestCase<UpdateRecurrenceCommand> {
@BeforeEach
void beforeEach() {
createTld("tld");
}
@Test
void testSuccess_setsSpecified() throws Exception {
persistDomain();
Recurring existingRecurring = Iterables.getOnlyElement(loadAllOf(Recurring.class));
assertThat(existingRecurring.getRecurrenceEndTime()).isEqualTo(END_OF_TIME);
assertThat(existingRecurring.getRenewalPriceBehavior()).isEqualTo(RenewalPriceBehavior.DEFAULT);
runCommandForced(
"domain.tld",
"--renewal_price_behavior",
"SPECIFIED",
"--specified_renewal_price",
"USD 9001");
assertThat(loadByEntity(existingRecurring).getRecurrenceEndTime())
.isEqualTo(fakeClock.nowUtc());
assertNewBillingEventAndHistory(
existingRecurring.getId(),
RenewalPriceBehavior.SPECIFIED,
Money.of(CurrencyUnit.USD, 9001));
}
@Test
void testSuccess_setsNonPremium() throws Exception {
persistDomain();
Recurring existingRecurring = Iterables.getOnlyElement(loadAllOf(Recurring.class));
assertThat(existingRecurring.getRecurrenceEndTime()).isEqualTo(END_OF_TIME);
assertThat(existingRecurring.getRenewalPriceBehavior()).isEqualTo(RenewalPriceBehavior.DEFAULT);
runCommandForced("domain.tld", "--renewal_price_behavior", "NONPREMIUM");
assertThat(loadByEntity(existingRecurring).getRecurrenceEndTime())
.isEqualTo(fakeClock.nowUtc());
assertNewBillingEventAndHistory(
existingRecurring.getId(), RenewalPriceBehavior.NONPREMIUM, null);
}
@Test
void testSuccess_setsDefault() throws Exception {
persistDomain();
Recurring existingRecurring = Iterables.getOnlyElement(loadAllOf(Recurring.class));
persistResource(
existingRecurring
.asBuilder()
.setRenewalPriceBehavior(RenewalPriceBehavior.SPECIFIED)
.setRenewalPrice(Money.of(CurrencyUnit.USD, 100))
.build());
assertThat(existingRecurring.getRecurrenceEndTime()).isEqualTo(END_OF_TIME);
runCommandForced("domain.tld", "--renewal_price_behavior", "DEFAULT");
assertThat(loadByEntity(existingRecurring).getRecurrenceEndTime())
.isEqualTo(fakeClock.nowUtc());
assertNewBillingEventAndHistory(existingRecurring.getId(), RenewalPriceBehavior.DEFAULT, null);
}
@Test
void testSuccess_setsPrice_whenSpecifiedAlready() throws Exception {
Domain domain = persistDomain();
Recurring recurring = loadByKey(domain.getAutorenewBillingEvent());
persistResource(
recurring
.asBuilder()
.setRenewalPrice(Money.of(CurrencyUnit.USD, 20))
.setRenewalPriceBehavior(RenewalPriceBehavior.SPECIFIED)
.build());
runCommandForced("domain.tld", "--specified_renewal_price", "USD 9001");
assertNewBillingEventAndHistory(
recurring.getId(), RenewalPriceBehavior.SPECIFIED, Money.of(CurrencyUnit.USD, 9001));
}
@Test
void testFailure_nonexistentDomain() {
assertThrows(
IllegalArgumentException.class,
() -> runCommandForced("nonexistent.tld", "--renewal_price_behavior", "NONPREMIUM"));
}
@Test
void testFailure_invalidInputs() throws Exception {
persistDomain();
assertThat(assertThrows(IllegalArgumentException.class, () -> runCommandForced("domain.tld")))
.hasMessageThat()
.isEqualTo("Must specify a behavior and/or a price");
command = newCommandInstance();
assertThat(
assertThrows(
IllegalArgumentException.class,
() ->
runCommandForced(
"domain.tld",
"--renewal_price_behavior",
"DEFAULT",
"--specified_renewal_price",
"USD 50")))
.hasMessageThat()
.isEqualTo(
"Renewal price can have a value if and only if the renewal price behavior is"
+ " SPECIFIED");
command = newCommandInstance();
assertThat(
assertThrows(
IllegalArgumentException.class,
() -> runCommandForced("domain.tld", "--renewal_price_behavior", "SPECIFIED")))
.hasMessageThat()
.isEqualTo("Renewal price must be set when using SPECIFIED behavior");
command = newCommandInstance();
assertThat(
assertThrows(
IllegalArgumentException.class,
() -> runCommandForced("domain.tld", "--specified_renewal_price", "USD 50")))
.hasMessageThat()
.isEqualTo(
"When specifying only a price, all domains must have SPECIFIED behavior. Domain"
+ " domain.tld does not");
}
@Test
void testFailure_billingAlreadyClosed() {
Domain domain = persistDomain();
Recurring recurring = loadByKey(domain.getAutorenewBillingEvent());
persistResource(recurring.asBuilder().setRecurrenceEndTime(fakeClock.nowUtc()).build());
assertThat(
assertThrows(
IllegalArgumentException.class,
() -> runCommandForced("domain.tld", "--renewal_price_behavior", "NONPREMIUM")))
.hasMessageThat()
.isEqualTo("Domain domain.tld's recurrence's end date is not END_OF_TIME");
}
@Test
void testFailure_pendingTransfer() {
persistDomainWithPendingTransfer(
persistDomain(),
fakeClock.nowUtc().minusMillis(1),
fakeClock.nowUtc().plusMonths(1),
END_OF_TIME);
assertThat(
assertThrows(
IllegalArgumentException.class,
() -> runCommandForced("domain.tld", "--renewal_price_behavior", "NONPREMIUM")))
.hasMessageThat()
.startsWith("Domain domain.tld has a pending transfer: DomainTransferData: {");
}
private void assertNewBillingEventAndHistory(
long previousId, RenewalPriceBehavior expectedBehavior, @Nullable Money expectedPrice) {
Recurring newRecurring =
loadAllOf(Recurring.class).stream().filter(r -> r.getId() != previousId).findFirst().get();
assertThat(newRecurring.getRecurrenceEndTime()).isEqualTo(END_OF_TIME);
assertThat(newRecurring.getRenewalPriceBehavior()).isEqualTo(expectedBehavior);
assertThat(newRecurring.getRenewalPrice()).isEqualTo(Optional.ofNullable(expectedPrice));
assertThat(newRecurring.getReason()).isEqualTo(Reason.RENEW);
DomainHistory newHistory =
loadAllOf(DomainHistory.class).stream()
.filter(dh -> !dh.getType().equals(HistoryEntry.Type.DOMAIN_CREATE))
.findFirst()
.get();
assertThat(newHistory.getType()).isEqualTo(HistoryEntry.Type.SYNTHETIC);
assertThat(newHistory.getReason())
.isEqualTo("Administrative update of billing recurrence behavior");
}
private Domain persistDomain() {
Domain domain =
persistDomainWithDependentResources(
"domain",
"tld",
persistActiveContact("contact1234"),
fakeClock.nowUtc(),
fakeClock.nowUtc(),
END_OF_TIME);
fakeClock.advanceOneMilli();
return domain;
}
}