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