diff --git a/core/src/main/java/google/registry/model/billing/BillingEvent.java b/core/src/main/java/google/registry/model/billing/BillingEvent.java index b9bb00758..5906a9dcb 100644 --- a/core/src/main/java/google/registry/model/billing/BillingEvent.java +++ b/core/src/main/java/google/registry/model/billing/BillingEvent.java @@ -126,7 +126,40 @@ public abstract class BillingEvent extends ImmutableObject * This flag will be added to any {@link OneTime} events that are created via, e.g., an * automated process to expand {@link Recurring} events. */ - SYNTHETIC + SYNTHETIC; + } + + /** + * Sets of renewal price behaviors that can be applied to billing recurrences. + * + *

When a client renews a domain, they could be charged differently, depending on factors such + * as the client type and the domain itself. + */ + public enum RenewalPriceBehavior { + /** + * This indicates the renewal price is the default price. + * + *

By default, if the domain is premium, then premium price will be used. Otherwise, the + * standard price of the TLD will be used. + */ + DEFAULT, + /** + * This indicates the domain will be renewed at standard price even if it's a premium domain. + * + *

We chose to name this "NONPREMIUM" rather than simply "STANDARD" to avoid confusion + * between "STANDARD" and "DEFAULT". + * + *

This price behavior is used with anchor tenants. + */ + NONPREMIUM, + /** + * This indicates that the renewalPrice in {@link BillingEvent.Recurring} will be used for + * domain renewal. + * + *

The renewalPrice has a non-null value iff the price behavior is set to "SPECIFIED". This + * behavior is used with internal registrations. + */ + SPECIFIED; } /** Entity id. */ @@ -555,6 +588,22 @@ public abstract class BillingEvent extends ImmutableObject }) TimeOfYear recurrenceTimeOfYear; + /** + * The renewal price for domain renewal if and only if it's specified. + * + *

This price column remains null except when the renewal price behavior of the billing is + * SPECIFIED. This column is used for internal registrations. + */ + @Nullable + @Type(type = JodaMoneyType.TYPE_NAME) + @Columns( + columns = {@Column(name = "renewalPriceAmount"), @Column(name = "renewalPriceCurrency")}) + Money renewalPrice; + + @Enumerated(EnumType.STRING) + @Column(name = "renewalPriceBehavior", nullable = false) + RenewalPriceBehavior renewalPriceBehavior = RenewalPriceBehavior.DEFAULT; + public DateTime getRecurrenceEndTime() { return recurrenceEndTime; } @@ -563,6 +612,14 @@ public abstract class BillingEvent extends ImmutableObject return recurrenceTimeOfYear; } + public RenewalPriceBehavior getRenewalPriceBehavior() { + return renewalPriceBehavior; + } + + public Optional getRenewalPrice() { + return Optional.ofNullable(renewalPrice); + } + @Override public VKey createVKey() { return VKey.create(Recurring.class, getId(), Key.create(this)); @@ -591,11 +648,26 @@ public abstract class BillingEvent extends ImmutableObject return this; } + public Builder setRenewalPriceBehavior(RenewalPriceBehavior renewalPriceBehavior) { + getInstance().renewalPriceBehavior = renewalPriceBehavior; + return this; + } + + public Builder setRenewalPrice(@Nullable Money renewalPrice) { + getInstance().renewalPrice = renewalPrice; + return this; + } + @Override public Recurring build() { Recurring instance = getInstance(); checkNotNull(instance.eventTime); checkNotNull(instance.reason); + checkArgument( + (instance.renewalPriceBehavior == RenewalPriceBehavior.SPECIFIED) + ^ (instance.renewalPrice == null), + "Renewal price can have a value if and only if the renewal price behavior is" + + " SPECIFIED"); instance.recurrenceTimeOfYear = TimeOfYear.fromDateTime(instance.eventTime); instance.recurrenceEndTime = Optional.ofNullable(instance.recurrenceEndTime).orElse(END_OF_TIME); diff --git a/core/src/main/java/google/registry/persistence/converter/JodaMoneyType.java b/core/src/main/java/google/registry/persistence/converter/JodaMoneyType.java index 681ce0a57..03dfbf385 100644 --- a/core/src/main/java/google/registry/persistence/converter/JodaMoneyType.java +++ b/core/src/main/java/google/registry/persistence/converter/JodaMoneyType.java @@ -21,6 +21,7 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.Objects; +import javax.annotation.Nullable; import org.hibernate.HibernateException; import org.hibernate.engine.spi.SharedSessionContractImplementor; import org.hibernate.type.StandardBasicTypes; @@ -116,20 +117,28 @@ public class JodaMoneyType implements CompositeUserType { return Objects.hashCode(x); } + @Nullable @Override public Object nullSafeGet( ResultSet rs, String[] names, SharedSessionContractImplementor session, Object owner) throws HibernateException, SQLException { BigDecimal amount = StandardBasicTypes.BIG_DECIMAL.nullSafeGet(rs, names[AMOUNT_ID], session); - CurrencyUnit currencyUnit = - CurrencyUnit.of(StandardBasicTypes.STRING.nullSafeGet(rs, names[CURRENCY_ID], session)); - if (amount != null && currencyUnit != null) { - return Money.of(currencyUnit, amount.stripTrailingZeros()); - } - if (amount == null && currencyUnit == null) { + String currencyUnitString = + StandardBasicTypes.STRING.nullSafeGet(rs, names[CURRENCY_ID], session); + // It is allowable for a Money object to be null, but only if both the currency unit and the + // amount are null + if (amount == null && currencyUnitString == null) { return null; + } else if (amount != null && currencyUnitString != null) { + // CurrencyUnit.of() throws an IllegalCurrencyException for unknown currency, which means the + // currency is valid if it returns a value + return Money.of(CurrencyUnit.of(currencyUnitString), amount.stripTrailingZeros()); + } else { + throw new HibernateException( + String.format( + "Mismatching null state between currency '%s' and amount '%s'", + currencyUnitString, amount)); } - throw new HibernateException("Mismatching null state between currency and amount."); } @Override @@ -140,7 +149,7 @@ public class JodaMoneyType implements CompositeUserType { String currencyUnit = value == null ? null : ((Money) value).getCurrencyUnit().getCode(); if ((amount == null && currencyUnit != null) || (amount != null && currencyUnit == null)) { - throw new HibernateException("Mismatching null state between currency and amount."); + throw new HibernateException("Mismatching null state between currency and amount"); } StandardBasicTypes.BIG_DECIMAL.nullSafeSet(st, amount, index, session); StandardBasicTypes.STRING.nullSafeSet(st, currencyUnit, index + 1, session); diff --git a/core/src/test/java/google/registry/model/billing/BillingEventTest.java b/core/src/test/java/google/registry/model/billing/BillingEventTest.java index 5acdd1c6a..f587d8d14 100644 --- a/core/src/test/java/google/registry/model/billing/BillingEventTest.java +++ b/core/src/test/java/google/registry/model/billing/BillingEventTest.java @@ -15,6 +15,7 @@ package google.registry.model.billing; import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth8.assertThat; import static google.registry.model.domain.token.AllocationToken.TokenType.UNLIMITED_USE; import static google.registry.model.ofy.ObjectifyService.auditedOfy; import static google.registry.persistence.transaction.TransactionManagerFactory.tm; @@ -36,6 +37,7 @@ import com.googlecode.objectify.Key; import google.registry.model.EntityTestCase; import google.registry.model.billing.BillingEvent.Flag; import google.registry.model.billing.BillingEvent.Reason; +import google.registry.model.billing.BillingEvent.RenewalPriceBehavior; import google.registry.model.domain.DomainBase; import google.registry.model.domain.DomainHistory; import google.registry.model.domain.GracePeriod; @@ -49,6 +51,7 @@ import google.registry.testing.TestOfyAndSql; import google.registry.testing.TestOfyOnly; import google.registry.testing.TestSqlOnly; import google.registry.util.DateTimeUtils; +import java.math.BigDecimal; import org.joda.money.Money; import org.joda.time.DateTime; import org.junit.jupiter.api.BeforeEach; @@ -474,4 +477,487 @@ public class BillingEventTest extends EntityTestCase { .setParent(domainHistory) .build(); } + + @TestOfyAndSql + void testSuccess_defaultRenewalPriceBehavior_assertsIsDefault() { + assertThat(recurring.getRenewalPriceBehavior()).isEqualTo(RenewalPriceBehavior.DEFAULT); + assertThat(recurring.getRenewalPrice()).isEmpty(); + } + + @TestOfyAndSql + void testSuccess_getRenewalPriceBehavior_returnsRightBehavior() { + BillingEvent.Recurring recurringEvent = + persistResource( + commonInit( + new BillingEvent.Recurring.Builder() + .setParent(domainHistory) + .setFlags(ImmutableSet.of(Flag.AUTO_RENEW)) + .setReason(Reason.RENEW) + .setEventTime(now.plusYears(1)) + .setRenewalPriceBehavior(RenewalPriceBehavior.NONPREMIUM) + .setRecurrenceEndTime(END_OF_TIME))); + assertThat(recurringEvent.getRenewalPriceBehavior()).isEqualTo(RenewalPriceBehavior.NONPREMIUM); + assertThat(recurringEvent.getRenewalPrice()).isEmpty(); + } + + @TestOfyAndSql + void testSuccess_setRenewalPriceBehaviorThenBuild_defaultToSpecified() { + BillingEvent.Recurring recurringEvent = + persistResource( + commonInit( + new BillingEvent.Recurring.Builder() + .setParent(domainHistory) + .setFlags(ImmutableSet.of(Flag.AUTO_RENEW)) + .setReason(Reason.RENEW) + .setEventTime(now.plusYears(1)) + .setRenewalPriceBehavior(RenewalPriceBehavior.DEFAULT) + .setRecurrenceEndTime(END_OF_TIME))); + assertThat(recurringEvent.getRenewalPriceBehavior()).isEqualTo(RenewalPriceBehavior.DEFAULT); + assertThat(recurringEvent.getRenewalPrice()).isEmpty(); + BillingEvent.Recurring loadedEntity = loadByEntity(recurringEvent); + assertThat(loadedEntity).isEqualTo(recurringEvent); + persistResource( + loadedEntity + .asBuilder() + .setRenewalPrice(Money.of(USD, 100)) + .setRenewalPriceBehavior(RenewalPriceBehavior.SPECIFIED) + .build()); + assertThat(loadByEntity(recurringEvent).getRenewalPriceBehavior()) + .isEqualTo(RenewalPriceBehavior.SPECIFIED); + assertThat(loadByEntity(recurringEvent).getRenewalPrice()).hasValue(Money.of(USD, 100)); + } + + @TestOfyAndSql + void testSuccess_setRenewalPriceBehaviorThenBuild_defaultToNonPremium() { + BillingEvent.Recurring recurringEvent = + persistResource( + commonInit( + new BillingEvent.Recurring.Builder() + .setParent(domainHistory) + .setFlags(ImmutableSet.of(Flag.AUTO_RENEW)) + .setReason(Reason.RENEW) + .setEventTime(now.plusYears(1)) + .setRenewalPriceBehavior(RenewalPriceBehavior.DEFAULT) + .setRecurrenceEndTime(END_OF_TIME))); + assertThat(recurringEvent.getRenewalPriceBehavior()).isEqualTo(RenewalPriceBehavior.DEFAULT); + assertThat(recurringEvent.getRenewalPrice()).isEmpty(); + BillingEvent.Recurring loadedEntity = loadByEntity(recurringEvent); + assertThat(loadedEntity).isEqualTo(recurringEvent); + persistResource( + loadedEntity.asBuilder().setRenewalPriceBehavior(RenewalPriceBehavior.NONPREMIUM).build()); + assertThat(loadByEntity(recurringEvent).getRenewalPriceBehavior()) + .isEqualTo(RenewalPriceBehavior.NONPREMIUM); + assertThat(loadByEntity(recurringEvent).getRenewalPrice()).isEmpty(); + } + + @TestOfyAndSql + void testSuccess_setRenewalPriceBehaviorThenBuild_nonPremiumToSpecified() { + BillingEvent.Recurring recurringEvent = + persistResource( + commonInit( + new BillingEvent.Recurring.Builder() + .setParent(domainHistory) + .setFlags(ImmutableSet.of(Flag.AUTO_RENEW)) + .setReason(Reason.RENEW) + .setEventTime(now.plusYears(1)) + .setRenewalPriceBehavior(RenewalPriceBehavior.NONPREMIUM) + .setRecurrenceEndTime(END_OF_TIME))); + assertThat(recurringEvent.getRenewalPriceBehavior()).isEqualTo(RenewalPriceBehavior.NONPREMIUM); + assertThat(recurringEvent.getRenewalPrice()).isEmpty(); + BillingEvent.Recurring loadedEntity = loadByEntity(recurringEvent); + assertThat(loadedEntity).isEqualTo(recurringEvent); + persistResource( + loadedEntity + .asBuilder() + .setRenewalPrice(Money.of(USD, 100)) + .setRenewalPriceBehavior(RenewalPriceBehavior.SPECIFIED) + .build()); + assertThat(loadByEntity(recurringEvent).getRenewalPriceBehavior()) + .isEqualTo(RenewalPriceBehavior.SPECIFIED); + assertThat(loadByEntity(recurringEvent).getRenewalPrice()).hasValue(Money.of(USD, 100)); + } + + @TestOfyAndSql + void testSuccess_setRenewalPriceBehaviorThenBuild_nonPremiumToDefault() { + BillingEvent.Recurring recurringEvent = + persistResource( + commonInit( + new BillingEvent.Recurring.Builder() + .setParent(domainHistory) + .setFlags(ImmutableSet.of(Flag.AUTO_RENEW)) + .setReason(Reason.RENEW) + .setEventTime(now.plusYears(1)) + .setRenewalPriceBehavior(RenewalPriceBehavior.NONPREMIUM) + .setRecurrenceEndTime(END_OF_TIME))); + assertThat(recurringEvent.getRenewalPriceBehavior()).isEqualTo(RenewalPriceBehavior.NONPREMIUM); + assertThat(recurringEvent.getRenewalPrice()).isEmpty(); + BillingEvent.Recurring loadedEntity = loadByEntity(recurringEvent); + assertThat(loadedEntity).isEqualTo(recurringEvent); + persistResource( + loadedEntity.asBuilder().setRenewalPriceBehavior(RenewalPriceBehavior.DEFAULT).build()); + assertThat(loadByEntity(recurringEvent).getRenewalPriceBehavior()) + .isEqualTo(RenewalPriceBehavior.DEFAULT); + assertThat(loadByEntity(recurringEvent).getRenewalPrice()).isEmpty(); + } + + @TestOfyAndSql + void testSuccess_setRenewalPriceBehaviorThenBuild_specifiedToDefault() { + BillingEvent.Recurring recurringEvent = + persistResource( + commonInit( + new BillingEvent.Recurring.Builder() + .setParent(domainHistory) + .setFlags(ImmutableSet.of(Flag.AUTO_RENEW)) + .setReason(Reason.RENEW) + .setEventTime(now.plusYears(1)) + .setRenewalPriceBehavior(RenewalPriceBehavior.SPECIFIED) + .setRenewalPrice(Money.of(USD, 100)) + .setRecurrenceEndTime(END_OF_TIME))); + assertThat(recurringEvent.getRenewalPriceBehavior()).isEqualTo(RenewalPriceBehavior.SPECIFIED); + assertThat(recurringEvent.getRenewalPrice()).hasValue(Money.of(USD, 100)); + BillingEvent.Recurring loadedEntity = loadByEntity(recurringEvent); + assertThat(loadedEntity).isEqualTo(recurringEvent); + persistResource( + loadedEntity + .asBuilder() + .setRenewalPrice(null) + .setRenewalPriceBehavior(RenewalPriceBehavior.DEFAULT) + .build()); + assertThat(loadByEntity(recurringEvent).getRenewalPriceBehavior()) + .isEqualTo(RenewalPriceBehavior.DEFAULT); + assertThat(loadByEntity(recurringEvent).getRenewalPrice()).isEmpty(); + } + + @TestOfyAndSql + void testSuccess_setRenewalPriceBehaviorThenBuild_specifiedToNonPremium() { + BillingEvent.Recurring recurringEvent = + persistResource( + commonInit( + new BillingEvent.Recurring.Builder() + .setParent(domainHistory) + .setFlags(ImmutableSet.of(Flag.AUTO_RENEW)) + .setReason(Reason.RENEW) + .setEventTime(now.plusYears(1)) + .setRenewalPriceBehavior(RenewalPriceBehavior.SPECIFIED) + .setRenewalPrice(Money.of(USD, 100)) + .setRecurrenceEndTime(END_OF_TIME))); + assertThat(recurringEvent.getRenewalPriceBehavior()).isEqualTo(RenewalPriceBehavior.SPECIFIED); + assertThat(recurringEvent.getRenewalPrice()).hasValue(Money.of(USD, 100)); + BillingEvent.Recurring loadedEntity = loadByEntity(recurringEvent); + assertThat(loadedEntity).isEqualTo(recurringEvent); + persistResource( + loadedEntity + .asBuilder() + .setRenewalPrice(null) + .setRenewalPriceBehavior(RenewalPriceBehavior.NONPREMIUM) + .build()); + assertThat(loadByEntity(recurringEvent).getRenewalPriceBehavior()) + .isEqualTo(RenewalPriceBehavior.NONPREMIUM); + assertThat(loadByEntity(recurringEvent).getRenewalPrice()).isEmpty(); + } + + @TestOfyAndSql + void testFailure_setRenewalPriceBehaviorThenBuild_defaultToSpecified_needRenewalPrice() { + BillingEvent.Recurring recurringEvent = + persistResource( + commonInit( + new BillingEvent.Recurring.Builder() + .setParent(domainHistory) + .setFlags(ImmutableSet.of(Flag.AUTO_RENEW)) + .setReason(Reason.RENEW) + .setEventTime(now.plusYears(1)) + .setRenewalPriceBehavior(RenewalPriceBehavior.DEFAULT) + .setRecurrenceEndTime(END_OF_TIME))); + assertThat(recurringEvent.getRenewalPriceBehavior()).isEqualTo(RenewalPriceBehavior.DEFAULT); + assertThat(recurringEvent.getRenewalPrice()).isEmpty(); + BillingEvent.Recurring loadedEntity = loadByEntity(recurringEvent); + assertThat(loadedEntity).isEqualTo(recurringEvent); + IllegalArgumentException thrown = + assertThrows( + IllegalArgumentException.class, + () -> + loadedEntity + .asBuilder() + .setRenewalPriceBehavior(RenewalPriceBehavior.SPECIFIED) + .build()); + assertThat(thrown) + .hasMessageThat() + .isEqualTo( + "Renewal price can have a value if and only if the " + + "renewal price behavior is SPECIFIED"); + } + + @TestOfyAndSql + void testFailure_setRenewalPriceBehaviorThenBuild_defaultToPremium_noNeedToAddRenewalPrice() { + BillingEvent.Recurring recurringEvent = + persistResource( + commonInit( + new BillingEvent.Recurring.Builder() + .setParent(domainHistory) + .setFlags(ImmutableSet.of(Flag.AUTO_RENEW)) + .setReason(Reason.RENEW) + .setEventTime(now.plusYears(1)) + .setRenewalPriceBehavior(RenewalPriceBehavior.DEFAULT) + .setRecurrenceEndTime(END_OF_TIME))); + assertThat(recurringEvent.getRenewalPriceBehavior()).isEqualTo(RenewalPriceBehavior.DEFAULT); + assertThat(recurringEvent.getRenewalPrice()).isEmpty(); + BillingEvent.Recurring loadedEntity = loadByEntity(recurringEvent); + assertThat(loadedEntity).isEqualTo(recurringEvent); + IllegalArgumentException thrown = + assertThrows( + IllegalArgumentException.class, + () -> + loadedEntity + .asBuilder() + .setRenewalPriceBehavior(RenewalPriceBehavior.NONPREMIUM) + .setRenewalPrice(Money.of(USD, 100)) + .build()); + assertThat(thrown) + .hasMessageThat() + .isEqualTo( + "Renewal price can have a value if and only if the " + + "renewal price behavior is SPECIFIED"); + } + + @TestOfyAndSql + void testFailure_setRenewalPriceBehaviorThenBuild_nonPremiumToDefault_noNeedToAddRenewalPrice() { + BillingEvent.Recurring recurringEvent = + persistResource( + commonInit( + new BillingEvent.Recurring.Builder() + .setParent(domainHistory) + .setFlags(ImmutableSet.of(Flag.AUTO_RENEW)) + .setReason(Reason.RENEW) + .setEventTime(now.plusYears(1)) + .setRenewalPriceBehavior(RenewalPriceBehavior.NONPREMIUM) + .setRecurrenceEndTime(END_OF_TIME))); + assertThat(recurringEvent.getRenewalPriceBehavior()).isEqualTo(RenewalPriceBehavior.NONPREMIUM); + assertThat(recurringEvent.getRenewalPrice()).isEmpty(); + BillingEvent.Recurring loadedEntity = loadByEntity(recurringEvent); + assertThat(loadedEntity).isEqualTo(recurringEvent); + IllegalArgumentException thrown = + assertThrows( + IllegalArgumentException.class, + () -> + loadedEntity + .asBuilder() + .setRenewalPriceBehavior(RenewalPriceBehavior.DEFAULT) + .setRenewalPrice(Money.of(USD, 100)) + .build()); + assertThat(thrown) + .hasMessageThat() + .isEqualTo( + "Renewal price can have a value if and only if the " + + "renewal price behavior is SPECIFIED"); + } + + @TestOfyAndSql + void testFailure_setRenewalPriceBehaviorThenBuild_nonPremiumToSpecified_needRenewalPrice() { + BillingEvent.Recurring recurringEvent = + persistResource( + commonInit( + new BillingEvent.Recurring.Builder() + .setParent(domainHistory) + .setFlags(ImmutableSet.of(Flag.AUTO_RENEW)) + .setReason(Reason.RENEW) + .setEventTime(now.plusYears(1)) + .setRenewalPriceBehavior(RenewalPriceBehavior.NONPREMIUM) + .setRecurrenceEndTime(END_OF_TIME))); + assertThat(recurringEvent.getRenewalPriceBehavior()).isEqualTo(RenewalPriceBehavior.NONPREMIUM); + assertThat(recurringEvent.getRenewalPrice()).isEmpty(); + BillingEvent.Recurring loadedEntity = loadByEntity(recurringEvent); + assertThat(loadedEntity).isEqualTo(recurringEvent); + IllegalArgumentException thrown = + assertThrows( + IllegalArgumentException.class, + () -> + loadedEntity + .asBuilder() + .setRenewalPriceBehavior(RenewalPriceBehavior.SPECIFIED) + .build()); + assertThat(thrown) + .hasMessageThat() + .isEqualTo( + "Renewal price can have a value if and only if the " + + "renewal price behavior is SPECIFIED"); + } + + @TestOfyAndSql + void testFailure_setRenewalPriceBehaviorThenBuild_specifiedToNonPremium_removeRenewalPrice() { + BillingEvent.Recurring recurringEvent = + persistResource( + commonInit( + new BillingEvent.Recurring.Builder() + .setParent(domainHistory) + .setFlags(ImmutableSet.of(Flag.AUTO_RENEW)) + .setReason(Reason.RENEW) + .setEventTime(now.plusYears(1)) + .setRenewalPriceBehavior(RenewalPriceBehavior.SPECIFIED) + .setRenewalPrice(Money.of(USD, 100)) + .setRecurrenceEndTime(END_OF_TIME))); + assertThat(recurringEvent.getRenewalPriceBehavior()).isEqualTo(RenewalPriceBehavior.SPECIFIED); + assertThat(recurringEvent.getRenewalPrice()).hasValue(Money.of(USD, 100)); + BillingEvent.Recurring loadedEntity = loadByEntity(recurringEvent); + assertThat(loadedEntity).isEqualTo(recurringEvent); + IllegalArgumentException thrown = + assertThrows( + IllegalArgumentException.class, + () -> + loadedEntity + .asBuilder() + .setRenewalPriceBehavior(RenewalPriceBehavior.NONPREMIUM) + .build()); + assertThat(thrown) + .hasMessageThat() + .isEqualTo( + "Renewal price can have a value if and only if the " + + "renewal price behavior is SPECIFIED"); + } + + @TestOfyAndSql + void testFailure_setRenewalPriceBehaviorThenBuild_specifiedToDefault_removeRenewalPrice() { + BillingEvent.Recurring recurringEvent = + persistResource( + commonInit( + new BillingEvent.Recurring.Builder() + .setParent(domainHistory) + .setFlags(ImmutableSet.of(Flag.AUTO_RENEW)) + .setReason(Reason.RENEW) + .setEventTime(now.plusYears(1)) + .setRenewalPriceBehavior(RenewalPriceBehavior.SPECIFIED) + .setRenewalPrice(Money.of(USD, 100)) + .setRecurrenceEndTime(END_OF_TIME))); + assertThat(recurringEvent.getRenewalPriceBehavior()).isEqualTo(RenewalPriceBehavior.SPECIFIED); + assertThat(recurringEvent.getRenewalPrice()).hasValue(Money.of(USD, 100)); + BillingEvent.Recurring loadedEntity = loadByEntity(recurringEvent); + assertThat(loadedEntity).isEqualTo(recurringEvent); + IllegalArgumentException thrown = + assertThrows( + IllegalArgumentException.class, + () -> + loadedEntity + .asBuilder() + .setRenewalPriceBehavior(RenewalPriceBehavior.DEFAULT) + .build()); + assertThat(thrown) + .hasMessageThat() + .isEqualTo( + "Renewal price can have a value if and only if the " + + "renewal price behavior is SPECIFIED"); + } + + @TestOfyAndSql + void testSuccess_buildWithDefaultRenewalBehavior() { + BillingEvent.Recurring recurringEvent = + persistResource( + commonInit( + new BillingEvent.Recurring.Builder() + .setParent(domainHistory) + .setFlags(ImmutableSet.of(Flag.AUTO_RENEW)) + .setReason(Reason.RENEW) + .setEventTime(now.plusYears(1)) + .setRenewalPriceBehavior(RenewalPriceBehavior.SPECIFIED) + .setRenewalPrice(Money.of(USD, BigDecimal.valueOf(100))) + .setRecurrenceEndTime(END_OF_TIME))); + assertThat(recurringEvent.getRenewalPriceBehavior()).isEqualTo(RenewalPriceBehavior.SPECIFIED); + assertThat(recurringEvent.getRenewalPrice()).hasValue(Money.of(USD, 100)); + } + + @TestOfyAndSql + void testSuccess_buildWithNonPremiumRenewalBehavior() { + BillingEvent.Recurring recurringEvent = + persistResource( + commonInit( + new BillingEvent.Recurring.Builder() + .setParent(domainHistory) + .setFlags(ImmutableSet.of(Flag.AUTO_RENEW)) + .setReason(Reason.RENEW) + .setEventTime(now.plusYears(1)) + .setRenewalPriceBehavior(RenewalPriceBehavior.NONPREMIUM) + .setRecurrenceEndTime(END_OF_TIME))); + assertThat(recurringEvent.getRenewalPriceBehavior()).isEqualTo(RenewalPriceBehavior.NONPREMIUM); + assertThat(loadByEntity(recurringEvent).getRenewalPrice()).isEmpty(); + } + + @TestOfyAndSql + void testSuccess_buildWithSpecifiedRenewalBehavior() { + BillingEvent.Recurring recurringEvent = + persistResource( + commonInit( + new BillingEvent.Recurring.Builder() + .setParent(domainHistory) + .setFlags(ImmutableSet.of(Flag.AUTO_RENEW)) + .setReason(Reason.RENEW) + .setEventTime(now.plusYears(1)) + .setRenewalPriceBehavior(RenewalPriceBehavior.SPECIFIED) + .setRenewalPrice(Money.of(USD, BigDecimal.valueOf(100))) + .setRecurrenceEndTime(END_OF_TIME))); + assertThat(recurringEvent.getRenewalPriceBehavior()).isEqualTo(RenewalPriceBehavior.SPECIFIED); + assertThat(recurringEvent.getRenewalPrice()).hasValue(Money.of(USD, 100)); + } + + @TestOfyAndSql + void testFailure_buildWithSpecifiedRenewalBehavior_requiresNonNullRenewalPrice() { + IllegalArgumentException thrown = + assertThrows( + IllegalArgumentException.class, + () -> + new BillingEvent.Recurring.Builder() + .setParent(domainHistory) + .setFlags(ImmutableSet.of(Flag.AUTO_RENEW)) + .setReason(Reason.RENEW) + .setEventTime(now.plusYears(1)) + .setRenewalPriceBehavior(RenewalPriceBehavior.SPECIFIED) + .setRecurrenceEndTime(END_OF_TIME) + .build()); + assertThat(thrown) + .hasMessageThat() + .isEqualTo( + "Renewal price can have a value if and only if the " + + "renewal price behavior is SPECIFIED"); + } + + @TestOfyAndSql + void testFailure_buildWithNonPremiumRenewalBehavior_requiresNullRenewalPrice() { + IllegalArgumentException thrown = + assertThrows( + IllegalArgumentException.class, + () -> + new BillingEvent.Recurring.Builder() + .setParent(domainHistory) + .setFlags(ImmutableSet.of(Flag.AUTO_RENEW)) + .setReason(Reason.RENEW) + .setEventTime(now.plusYears(1)) + .setRenewalPriceBehavior(RenewalPriceBehavior.NONPREMIUM) + .setRenewalPrice(Money.of(USD, BigDecimal.valueOf(100))) + .setRecurrenceEndTime(END_OF_TIME) + .build()); + assertThat(thrown) + .hasMessageThat() + .isEqualTo( + "Renewal price can have a value if and only if the " + + "renewal price behavior is SPECIFIED"); + } + + @TestOfyAndSql + void testFailure_buildWithDefaultRenewalBehavior_requiresNullRenewalPrice() { + IllegalArgumentException thrown = + assertThrows( + IllegalArgumentException.class, + () -> + new BillingEvent.Recurring.Builder() + .setParent(domainHistory) + .setFlags(ImmutableSet.of(Flag.AUTO_RENEW)) + .setReason(Reason.RENEW) + .setEventTime(now.plusYears(1)) + .setRenewalPriceBehavior(RenewalPriceBehavior.DEFAULT) + .setRenewalPrice(Money.of(USD, BigDecimal.valueOf(100))) + .setRecurrenceEndTime(END_OF_TIME) + .build()); + assertThat(thrown) + .hasMessageThat() + .isEqualTo( + "Renewal price can have a value if and only if the " + + "renewal price behavior is SPECIFIED"); + } } diff --git a/core/src/test/java/google/registry/persistence/converter/JodaMoneyConverterTest.java b/core/src/test/java/google/registry/persistence/converter/JodaMoneyConverterTest.java index e3d767570..b7869634c 100644 --- a/core/src/test/java/google/registry/persistence/converter/JodaMoneyConverterTest.java +++ b/core/src/test/java/google/registry/persistence/converter/JodaMoneyConverterTest.java @@ -16,6 +16,7 @@ package google.registry.persistence.converter; import static com.google.common.truth.Truth.assertThat; import static google.registry.persistence.transaction.TransactionManagerFactory.jpaTm; import static google.registry.testing.DatabaseHelper.insertInDb; +import static org.junit.jupiter.api.Assertions.assertThrows; import com.google.common.collect.ImmutableMap; import google.registry.model.ImmutableObject; @@ -23,6 +24,7 @@ import google.registry.model.replay.EntityTest.EntityForTesting; import google.registry.persistence.transaction.JpaTestExtensions; import google.registry.persistence.transaction.JpaTestExtensions.JpaUnitTestExtension; import java.math.BigDecimal; +import java.sql.SQLException; import java.util.Arrays; import java.util.List; import java.util.Map; @@ -34,9 +36,11 @@ import javax.persistence.FetchType; import javax.persistence.Id; import javax.persistence.JoinColumn; import javax.persistence.MapKeyColumn; +import javax.persistence.PersistenceException; import org.hibernate.annotations.Columns; import org.hibernate.annotations.Type; import org.joda.money.CurrencyUnit; +import org.joda.money.IllegalCurrencyException; import org.joda.money.Money; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -65,7 +69,7 @@ public class JodaMoneyConverterTest { .createNativeQuery( "SELECT amount, currency FROM \"TestEntity\" WHERE name = 'id'") .getResultList()); - assertThat(result.size()).isEqualTo(1); + assertThat(result).hasSize(1); // The amount property, when loaded as a raw value, has the same scale as the table column, // which is 2. assertThat(Arrays.asList((Object[]) result.get(0))) @@ -91,7 +95,7 @@ public class JodaMoneyConverterTest { .createNativeQuery( "SELECT amount, currency FROM \"TestEntity\" WHERE name = 'id'") .getResultList()); - assertThat(result.size()).isEqualTo(1); + assertThat(result).hasSize(1); /* The amount property, when loaded as a raw value, has the same scale as the table column, which is 2. */ assertThat(Arrays.asList((Object[]) result.get(0))) @@ -136,7 +140,7 @@ public class JodaMoneyConverterTest { "SELECT my_amount, my_currency, your_amount, your_currency FROM" + " \"ComplexTestEntity\" WHERE name = 'id'") .getResultList()); - assertThat(result.size()).isEqualTo(1); + assertThat(result).hasSize(1); assertThat(Arrays.asList((Object[]) result.get(0))) .containsExactly( BigDecimal.valueOf(100).setScale(2), "USD", BigDecimal.valueOf(80).setScale(2), "GBP") @@ -153,7 +157,7 @@ public class JodaMoneyConverterTest { .getResultList()); ComplexTestEntity persisted = jpaTm().transact(() -> jpaTm().getEntityManager().find(ComplexTestEntity.class, "id")); - assertThat(result.size()).isEqualTo(1); + assertThat(result).hasSize(1); assertThat(Arrays.asList((Object[]) result.get(0))) .containsExactly(BigDecimal.valueOf(2000).setScale(2), "JPY") @@ -164,6 +168,124 @@ public class JodaMoneyConverterTest { assertThat(persisted.moneyMap).containsExactlyEntriesIn(moneyMap); } + /** + * Implicit test cases for @override method @nullSafeGet when constructing {@link Money} object + * with null/invalid column(s). + */ + @Test + void testNullSafeGet_nullAmountNullCurrency_returnsNull() throws SQLException { + jpaTm() + .transact( + () -> + jpaTm() + .getEntityManager() + .createNativeQuery( + "INSERT INTO \"TestEntity\" (name, amount, currency) VALUES('id', null," + + " null)") + .executeUpdate()); + List result = + jpaTm() + .transact( + () -> + jpaTm() + .getEntityManager() + .createNativeQuery( + "SELECT amount, currency FROM \"TestEntity\" WHERE name = 'id'") + .getResultList()); + assertThat(result).hasSize(1); + assertThat(Arrays.asList((Object[]) result.get(0))).containsExactly(null, null).inOrder(); + assertThat( + jpaTm() + .transact( + () -> + jpaTm() + .getEntityManager() + .createQuery("SELECT money FROM TestEntity WHERE name = 'id'") + .getResultList()) + .get(0)) + .isNull(); + } + + @Test + void testNullSafeGet_nullAMountValidCurrency_throwsHibernateException() throws SQLException { + jpaTm() + .transact( + () -> + jpaTm() + .getEntityManager() + .createNativeQuery( + "INSERT INTO \"TestEntity\" (name, amount, currency) VALUES('id', null," + + " 'USD')") + .executeUpdate()); + List result = + jpaTm() + .transact( + () -> + jpaTm() + .getEntityManager() + .createNativeQuery( + "SELECT amount, currency FROM \"TestEntity\" WHERE name = 'id'") + .getResultList()); + assertThat(Arrays.asList((Object[]) result.get(0))).containsExactly(null, "USD"); + // CurrencyUnit.of() throws HibernateException for invalid currency which leads to persistance + // error + PersistenceException thrown = + assertThrows( + PersistenceException.class, + () -> + jpaTm() + .transact( + () -> + jpaTm() + .getEntityManager() + .createQuery("SELECT money FROM TestEntity WHERE name = 'id'") + .getResultList())); + assertThat(thrown) + .hasMessageThat() + .isEqualTo( + "org.hibernate.HibernateException: Mismatching null state between currency 'USD' and" + + " amount 'null'"); + } + + @Test + void testNullSafeGet_nullAMountInValidCurrency_throwsIllegalCurrencyException() + throws SQLException { + jpaTm() + .transact( + () -> + jpaTm() + .getEntityManager() + .createNativeQuery( + "INSERT INTO \"TestEntity\" (name, amount, currency) VALUES('id', 100," + + " 'INVALIDCURRENCY')") + .executeUpdate()); + List result = + jpaTm() + .transact( + () -> + jpaTm() + .getEntityManager() + .createNativeQuery( + "SELECT amount, currency FROM \"TestEntity\" WHERE name = 'id'") + .getResultList()); + assertThat(result).hasSize(1); + assertThat(Arrays.asList((Object[]) result.get(0))) + .containsExactly(BigDecimal.valueOf(100).setScale(2), "INVALIDCURRENCY") + .inOrder(); + IllegalCurrencyException thrown = + assertThrows( + IllegalCurrencyException.class, + () -> + jpaTm() + .transact( + () -> + jpaTm() + .getEntityManager() + .createQuery("SELECT money FROM TestEntity WHERE name = 'id'") + .getResultList())); + assertThat(thrown).hasMessageThat().isEqualTo("Unknown currency 'INVALIDCURRENCY'"); + } + // Override entity name to exclude outer-class name in table name. Not necessary if class is not // inner class. @Entity(name = "TestEntity") diff --git a/core/src/test/resources/google/registry/model/schema.txt b/core/src/test/resources/google/registry/model/schema.txt index 20374c039..da20e9c5b 100644 --- a/core/src/test/resources/google/registry/model/schema.txt +++ b/core/src/test/resources/google/registry/model/schema.txt @@ -65,13 +65,20 @@ class google.registry.model.billing.BillingEvent$Recurring { @Id java.lang.Long id; @Parent com.googlecode.objectify.Key parent; google.registry.model.billing.BillingEvent$Reason reason; + google.registry.model.billing.BillingEvent$RenewalPriceBehavior renewalPriceBehavior; google.registry.model.common.TimeOfYear recurrenceTimeOfYear; java.lang.String clientId; java.lang.String targetId; java.util.Set flags; + org.joda.money.Money renewalPrice; org.joda.time.DateTime eventTime; org.joda.time.DateTime recurrenceEndTime; } +enum google.registry.model.billing.BillingEvent$RenewalPriceBehavior { + DEFAULT; + NONPREMIUM; + SPECIFIED; +} class google.registry.model.common.Cursor { @Id java.lang.String id; @Parent com.googlecode.objectify.Key parent; diff --git a/db/src/main/resources/sql/schema/db-schema.sql.generated b/db/src/main/resources/sql/schema/db-schema.sql.generated index 4c1a5dec1..719f37f8a 100644 --- a/db/src/main/resources/sql/schema/db-schema.sql.generated +++ b/db/src/main/resources/sql/schema/db-schema.sql.generated @@ -79,6 +79,9 @@ domain_name text not null, recurrence_end_time timestamptz, recurrence_time_of_year text, + renewal_price_amount numeric(19, 2), + renewal_price_currency text, + renewal_price_behavior text not null, primary key (billing_recurrence_id) );