mirror of
https://github.com/google/nomulus.git
synced 2025-04-29 19:47:51 +02:00
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:
parent
dcc7e65813
commit
f985cfd749
3 changed files with 418 additions and 0 deletions
|
@ -108,6 +108,7 @@ public final class RegistryTool {
|
|||
.put("update_keyring_secret", UpdateKeyringSecretCommand.class)
|
||||
.put("update_package_promotion", UpdatePackagePromotionCommand.class)
|
||||
.put("update_premium_list", UpdatePremiumListCommand.class)
|
||||
.put("update_recurrence", UpdateRecurrenceCommand.class)
|
||||
.put("update_registrar", UpdateRegistrarCommand.class)
|
||||
.put("update_reserved_list", UpdateReservedListCommand.class)
|
||||
.put("update_server_locks", UpdateServerLocksCommand.class)
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue