Add new columns to BillingEvent (#1573)

* Add new columns to BillingEvent.java

* Improve PR and modifyJodaMoneyType to handle null currency in override

* Add test cases for edge cases of nullSafeGet in JodaMoneyType

* Improve assertions
This commit is contained in:
Rachel Guan 2022-04-11 20:09:26 -04:00 committed by GitHub
parent 430e136920
commit f79e4740f6
6 changed files with 712 additions and 13 deletions

View file

@ -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 * This flag will be added to any {@link OneTime} events that are created via, e.g., an
* automated process to expand {@link Recurring} events. * automated process to expand {@link Recurring} events.
*/ */
SYNTHETIC SYNTHETIC;
}
/**
* Sets of renewal price behaviors that can be applied to billing recurrences.
*
* <p>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.
*
* <p>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.
*
* <p>We chose to name this "NONPREMIUM" rather than simply "STANDARD" to avoid confusion
* between "STANDARD" and "DEFAULT".
*
* <p>This price behavior is used with anchor tenants.
*/
NONPREMIUM,
/**
* This indicates that the renewalPrice in {@link BillingEvent.Recurring} will be used for
* domain renewal.
*
* <p>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. */ /** Entity id. */
@ -555,6 +588,22 @@ public abstract class BillingEvent extends ImmutableObject
}) })
TimeOfYear recurrenceTimeOfYear; TimeOfYear recurrenceTimeOfYear;
/**
* The renewal price for domain renewal if and only if it's specified.
*
* <p>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() { public DateTime getRecurrenceEndTime() {
return recurrenceEndTime; return recurrenceEndTime;
} }
@ -563,6 +612,14 @@ public abstract class BillingEvent extends ImmutableObject
return recurrenceTimeOfYear; return recurrenceTimeOfYear;
} }
public RenewalPriceBehavior getRenewalPriceBehavior() {
return renewalPriceBehavior;
}
public Optional<Money> getRenewalPrice() {
return Optional.ofNullable(renewalPrice);
}
@Override @Override
public VKey<Recurring> createVKey() { public VKey<Recurring> createVKey() {
return VKey.create(Recurring.class, getId(), Key.create(this)); return VKey.create(Recurring.class, getId(), Key.create(this));
@ -591,11 +648,26 @@ public abstract class BillingEvent extends ImmutableObject
return this; return this;
} }
public Builder setRenewalPriceBehavior(RenewalPriceBehavior renewalPriceBehavior) {
getInstance().renewalPriceBehavior = renewalPriceBehavior;
return this;
}
public Builder setRenewalPrice(@Nullable Money renewalPrice) {
getInstance().renewalPrice = renewalPrice;
return this;
}
@Override @Override
public Recurring build() { public Recurring build() {
Recurring instance = getInstance(); Recurring instance = getInstance();
checkNotNull(instance.eventTime); checkNotNull(instance.eventTime);
checkNotNull(instance.reason); 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.recurrenceTimeOfYear = TimeOfYear.fromDateTime(instance.eventTime);
instance.recurrenceEndTime = instance.recurrenceEndTime =
Optional.ofNullable(instance.recurrenceEndTime).orElse(END_OF_TIME); Optional.ofNullable(instance.recurrenceEndTime).orElse(END_OF_TIME);

View file

@ -21,6 +21,7 @@ import java.sql.PreparedStatement;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.sql.SQLException; import java.sql.SQLException;
import java.util.Objects; import java.util.Objects;
import javax.annotation.Nullable;
import org.hibernate.HibernateException; import org.hibernate.HibernateException;
import org.hibernate.engine.spi.SharedSessionContractImplementor; import org.hibernate.engine.spi.SharedSessionContractImplementor;
import org.hibernate.type.StandardBasicTypes; import org.hibernate.type.StandardBasicTypes;
@ -116,20 +117,28 @@ public class JodaMoneyType implements CompositeUserType {
return Objects.hashCode(x); return Objects.hashCode(x);
} }
@Nullable
@Override @Override
public Object nullSafeGet( public Object nullSafeGet(
ResultSet rs, String[] names, SharedSessionContractImplementor session, Object owner) ResultSet rs, String[] names, SharedSessionContractImplementor session, Object owner)
throws HibernateException, SQLException { throws HibernateException, SQLException {
BigDecimal amount = StandardBasicTypes.BIG_DECIMAL.nullSafeGet(rs, names[AMOUNT_ID], session); BigDecimal amount = StandardBasicTypes.BIG_DECIMAL.nullSafeGet(rs, names[AMOUNT_ID], session);
CurrencyUnit currencyUnit = String currencyUnitString =
CurrencyUnit.of(StandardBasicTypes.STRING.nullSafeGet(rs, names[CURRENCY_ID], session)); StandardBasicTypes.STRING.nullSafeGet(rs, names[CURRENCY_ID], session);
if (amount != null && currencyUnit != null) { // It is allowable for a Money object to be null, but only if both the currency unit and the
return Money.of(currencyUnit, amount.stripTrailingZeros()); // amount are null
} if (amount == null && currencyUnitString == null) {
if (amount == null && currencyUnit == null) {
return 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 @Override
@ -140,7 +149,7 @@ public class JodaMoneyType implements CompositeUserType {
String currencyUnit = value == null ? null : ((Money) value).getCurrencyUnit().getCode(); String currencyUnit = value == null ? null : ((Money) value).getCurrencyUnit().getCode();
if ((amount == null && currencyUnit != null) || (amount != null && currencyUnit == null)) { 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.BIG_DECIMAL.nullSafeSet(st, amount, index, session);
StandardBasicTypes.STRING.nullSafeSet(st, currencyUnit, index + 1, session); StandardBasicTypes.STRING.nullSafeSet(st, currencyUnit, index + 1, session);

View file

@ -15,6 +15,7 @@
package google.registry.model.billing; package google.registry.model.billing;
import static com.google.common.truth.Truth.assertThat; 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.domain.token.AllocationToken.TokenType.UNLIMITED_USE;
import static google.registry.model.ofy.ObjectifyService.auditedOfy; import static google.registry.model.ofy.ObjectifyService.auditedOfy;
import static google.registry.persistence.transaction.TransactionManagerFactory.tm; 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.EntityTestCase;
import google.registry.model.billing.BillingEvent.Flag; import google.registry.model.billing.BillingEvent.Flag;
import google.registry.model.billing.BillingEvent.Reason; 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.DomainBase;
import google.registry.model.domain.DomainHistory; import google.registry.model.domain.DomainHistory;
import google.registry.model.domain.GracePeriod; import google.registry.model.domain.GracePeriod;
@ -49,6 +51,7 @@ import google.registry.testing.TestOfyAndSql;
import google.registry.testing.TestOfyOnly; import google.registry.testing.TestOfyOnly;
import google.registry.testing.TestSqlOnly; import google.registry.testing.TestSqlOnly;
import google.registry.util.DateTimeUtils; import google.registry.util.DateTimeUtils;
import java.math.BigDecimal;
import org.joda.money.Money; import org.joda.money.Money;
import org.joda.time.DateTime; import org.joda.time.DateTime;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@ -474,4 +477,487 @@ public class BillingEventTest extends EntityTestCase {
.setParent(domainHistory) .setParent(domainHistory)
.build(); .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");
}
} }

View file

@ -16,6 +16,7 @@ package google.registry.persistence.converter;
import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertThat;
import static google.registry.persistence.transaction.TransactionManagerFactory.jpaTm; import static google.registry.persistence.transaction.TransactionManagerFactory.jpaTm;
import static google.registry.testing.DatabaseHelper.insertInDb; import static google.registry.testing.DatabaseHelper.insertInDb;
import static org.junit.jupiter.api.Assertions.assertThrows;
import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableMap;
import google.registry.model.ImmutableObject; 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;
import google.registry.persistence.transaction.JpaTestExtensions.JpaUnitTestExtension; import google.registry.persistence.transaction.JpaTestExtensions.JpaUnitTestExtension;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.sql.SQLException;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -34,9 +36,11 @@ import javax.persistence.FetchType;
import javax.persistence.Id; import javax.persistence.Id;
import javax.persistence.JoinColumn; import javax.persistence.JoinColumn;
import javax.persistence.MapKeyColumn; import javax.persistence.MapKeyColumn;
import javax.persistence.PersistenceException;
import org.hibernate.annotations.Columns; import org.hibernate.annotations.Columns;
import org.hibernate.annotations.Type; import org.hibernate.annotations.Type;
import org.joda.money.CurrencyUnit; import org.joda.money.CurrencyUnit;
import org.joda.money.IllegalCurrencyException;
import org.joda.money.Money; import org.joda.money.Money;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.api.extension.RegisterExtension;
@ -65,7 +69,7 @@ public class JodaMoneyConverterTest {
.createNativeQuery( .createNativeQuery(
"SELECT amount, currency FROM \"TestEntity\" WHERE name = 'id'") "SELECT amount, currency FROM \"TestEntity\" WHERE name = 'id'")
.getResultList()); .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, // The amount property, when loaded as a raw value, has the same scale as the table column,
// which is 2. // which is 2.
assertThat(Arrays.asList((Object[]) result.get(0))) assertThat(Arrays.asList((Object[]) result.get(0)))
@ -91,7 +95,7 @@ public class JodaMoneyConverterTest {
.createNativeQuery( .createNativeQuery(
"SELECT amount, currency FROM \"TestEntity\" WHERE name = 'id'") "SELECT amount, currency FROM \"TestEntity\" WHERE name = 'id'")
.getResultList()); .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, /* The amount property, when loaded as a raw value, has the same scale as the table column,
which is 2. */ which is 2. */
assertThat(Arrays.asList((Object[]) result.get(0))) assertThat(Arrays.asList((Object[]) result.get(0)))
@ -136,7 +140,7 @@ public class JodaMoneyConverterTest {
"SELECT my_amount, my_currency, your_amount, your_currency FROM" "SELECT my_amount, my_currency, your_amount, your_currency FROM"
+ " \"ComplexTestEntity\" WHERE name = 'id'") + " \"ComplexTestEntity\" WHERE name = 'id'")
.getResultList()); .getResultList());
assertThat(result.size()).isEqualTo(1); assertThat(result).hasSize(1);
assertThat(Arrays.asList((Object[]) result.get(0))) assertThat(Arrays.asList((Object[]) result.get(0)))
.containsExactly( .containsExactly(
BigDecimal.valueOf(100).setScale(2), "USD", BigDecimal.valueOf(80).setScale(2), "GBP") BigDecimal.valueOf(100).setScale(2), "USD", BigDecimal.valueOf(80).setScale(2), "GBP")
@ -153,7 +157,7 @@ public class JodaMoneyConverterTest {
.getResultList()); .getResultList());
ComplexTestEntity persisted = ComplexTestEntity persisted =
jpaTm().transact(() -> jpaTm().getEntityManager().find(ComplexTestEntity.class, "id")); jpaTm().transact(() -> jpaTm().getEntityManager().find(ComplexTestEntity.class, "id"));
assertThat(result.size()).isEqualTo(1); assertThat(result).hasSize(1);
assertThat(Arrays.asList((Object[]) result.get(0))) assertThat(Arrays.asList((Object[]) result.get(0)))
.containsExactly(BigDecimal.valueOf(2000).setScale(2), "JPY") .containsExactly(BigDecimal.valueOf(2000).setScale(2), "JPY")
@ -164,6 +168,124 @@ public class JodaMoneyConverterTest {
assertThat(persisted.moneyMap).containsExactlyEntriesIn(moneyMap); 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 // Override entity name to exclude outer-class name in table name. Not necessary if class is not
// inner class. // inner class.
@Entity(name = "TestEntity") @Entity(name = "TestEntity")

View file

@ -65,13 +65,20 @@ class google.registry.model.billing.BillingEvent$Recurring {
@Id java.lang.Long id; @Id java.lang.Long id;
@Parent com.googlecode.objectify.Key<google.registry.model.domain.DomainHistory> parent; @Parent com.googlecode.objectify.Key<google.registry.model.domain.DomainHistory> parent;
google.registry.model.billing.BillingEvent$Reason reason; google.registry.model.billing.BillingEvent$Reason reason;
google.registry.model.billing.BillingEvent$RenewalPriceBehavior renewalPriceBehavior;
google.registry.model.common.TimeOfYear recurrenceTimeOfYear; google.registry.model.common.TimeOfYear recurrenceTimeOfYear;
java.lang.String clientId; java.lang.String clientId;
java.lang.String targetId; java.lang.String targetId;
java.util.Set<google.registry.model.billing.BillingEvent$Flag> flags; java.util.Set<google.registry.model.billing.BillingEvent$Flag> flags;
org.joda.money.Money renewalPrice;
org.joda.time.DateTime eventTime; org.joda.time.DateTime eventTime;
org.joda.time.DateTime recurrenceEndTime; org.joda.time.DateTime recurrenceEndTime;
} }
enum google.registry.model.billing.BillingEvent$RenewalPriceBehavior {
DEFAULT;
NONPREMIUM;
SPECIFIED;
}
class google.registry.model.common.Cursor { class google.registry.model.common.Cursor {
@Id java.lang.String id; @Id java.lang.String id;
@Parent com.googlecode.objectify.Key<google.registry.model.common.EntityGroupRoot> parent; @Parent com.googlecode.objectify.Key<google.registry.model.common.EntityGroupRoot> parent;

View file

@ -79,6 +79,9 @@
domain_name text not null, domain_name text not null,
recurrence_end_time timestamptz, recurrence_end_time timestamptz,
recurrence_time_of_year text, 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) primary key (billing_recurrence_id)
); );