From 081284d5a58c86b8fdc52a8ac2f10ed6b152c250 Mon Sep 17 00:00:00 2001 From: Yann Simon Date: Thu, 28 Feb 2019 16:57:40 +0100 Subject: [PATCH 1/2] MongoFormat for BaseMoney --- .../main/scala/io/sphere/json/FromJSON.scala | 20 ++-- .../main/scala/io/sphere/json/ToJSON.scala | 21 ++-- .../mongo/format/DefaultMongoFormats.scala | 87 ++++++++++++++++- .../format/BaseMoneyMongoFormatTest.scala | 97 +++++++++++++++++++ util/src/main/scala/Money.scala | 14 ++- 5 files changed, 218 insertions(+), 21 deletions(-) create mode 100644 mongo/src/test/scala/io/sphere/mongo/format/BaseMoneyMongoFormatTest.scala diff --git a/json/src/main/scala/io/sphere/json/FromJSON.scala b/json/src/main/scala/io/sphere/json/FromJSON.scala index 1888446e..1f8109ef 100644 --- a/json/src/main/scala/io/sphere/json/FromJSON.scala +++ b/json/src/main/scala/io/sphere/json/FromJSON.scala @@ -168,11 +168,13 @@ object FromJSON { } implicit val moneyReader: FromJSON[Money] = new FromJSON[Money] { - override val fields = Set("centAmount", "currencyCode") + import Money._ + + override val fields = Set(CentAmountField, CurrencyCodeField) def read(value: JValue): JValidation[Money] = value match { case o: JObject ⇒ - (field[Long]("centAmount")(o), field[Currency]("currencyCode")(o)).mapN( + (field[Long](CentAmountField)(o), field[Currency](CurrencyCodeField)(o)).mapN( Money.fromCentAmount) case _ ⇒ fail("JSON object expected.") @@ -180,17 +182,19 @@ object FromJSON { } implicit val highPrecisionMoneyReader: FromJSON[HighPrecisionMoney] = new FromJSON[HighPrecisionMoney] { - override val fields = Set("preciseAmount", "currencyCode", "fractionDigits") + import HighPrecisionMoney._ + + override val fields = Set(PreciseAmountField, CurrencyCodeField, FractionDigitsField) import cats.implicits._ def read(value: JValue): JValidation[HighPrecisionMoney] = value match { case o: JObject ⇒ val validatedFields = ( - field[Long]("preciseAmount")(o), - field[Int]("fractionDigits")(o), - field[Currency]("currencyCode")(o), - field[Option[Long]]("centAmount")(o)) + field[Long](PreciseAmountField)(o), + field[Int](FractionDigitsField)(o), + field[Currency](CurrencyCodeField)(o), + field[Option[Long]](CentAmountField)(o)) validatedFields.tupled.andThen { case (preciseAmount, fractionDigits, currencyCode, centAmount) ⇒ HighPrecisionMoney.fromPreciseAmount(preciseAmount, fractionDigits, currencyCode, centAmount).leftMap(_.map(JSONParseError(_))) @@ -204,7 +208,7 @@ object FromJSON { implicit val baseMoneyReader: FromJSON[BaseMoney] = new FromJSON[BaseMoney] { def read(value: JValue): JValidation[BaseMoney] = value match { case o: JObject ⇒ - field[Option[String]]("type")(o).andThen { + field[Option[String]](BaseMoney.TypeField)(o).andThen { case None ⇒ moneyReader.read(value) case Some(Money.TypeName) ⇒ moneyReader.read(value) case Some(HighPrecisionMoney.TypeName) ⇒ highPrecisionMoneyReader.read(value) diff --git a/json/src/main/scala/io/sphere/json/ToJSON.scala b/json/src/main/scala/io/sphere/json/ToJSON.scala index 24b1cfb9..870f415e 100644 --- a/json/src/main/scala/io/sphere/json/ToJSON.scala +++ b/json/src/main/scala/io/sphere/json/ToJSON.scala @@ -92,22 +92,25 @@ object ToJSON { } implicit val moneyWriter: ToJSON[Money] = new ToJSON[Money] { + import Money._ + def write(m: Money): JValue = JObject( - JField("type", toJValue(m.`type`)) :: - JField("currencyCode", toJValue(m.currency)) :: - JField("centAmount", toJValue(m.centAmount)) :: - JField("fractionDigits", toJValue(m.currency.getDefaultFractionDigits)) :: + JField(BaseMoney.TypeField, toJValue(m.`type`)) :: + JField(CurrencyCodeField, toJValue(m.currency)) :: + JField(CentAmountField, toJValue(m.centAmount)) :: + JField(FractionDigitsField, toJValue(m.currency.getDefaultFractionDigits)) :: Nil ) } implicit val highPrecisionMoneyWriter: ToJSON[HighPrecisionMoney] = new ToJSON[HighPrecisionMoney] { + import HighPrecisionMoney._ def write(m: HighPrecisionMoney): JValue = JObject( - JField("type", toJValue(m.`type`)) :: - JField("currencyCode", toJValue(m.currency)) :: - JField("centAmount", toJValue(m.centAmount)) :: - JField("preciseAmount", toJValue(m.preciseAmountAsLong)) :: - JField("fractionDigits", toJValue(m.fractionDigits)) :: + JField(BaseMoney.TypeField, toJValue(m.`type`)) :: + JField(CurrencyCodeField, toJValue(m.currency)) :: + JField(CentAmountField, toJValue(m.centAmount)) :: + JField(PreciseAmountField, toJValue(m.preciseAmountAsLong)) :: + JField(FractionDigitsField, toJValue(m.fractionDigits)) :: Nil ) } diff --git a/mongo/src/main/scala/io/sphere/mongo/format/DefaultMongoFormats.scala b/mongo/src/main/scala/io/sphere/mongo/format/DefaultMongoFormats.scala index 8656716a..cade6ff2 100644 --- a/mongo/src/main/scala/io/sphere/mongo/format/DefaultMongoFormats.scala +++ b/mongo/src/main/scala/io/sphere/mongo/format/DefaultMongoFormats.scala @@ -1,10 +1,11 @@ package io.sphere.mongo.format -import java.util.UUID +import java.util.{Currency, UUID} import java.util.regex.Pattern import com.mongodb.{BasicDBList, DBObject} -import org.bson.BasicBSONObject +import io.sphere.util.{BaseMoney, HighPrecisionMoney, Money} +import org.bson.{BSONObject, BasicBSONObject} import org.bson.types.ObjectId object DefaultMongoFormats extends DefaultMongoFormats { @@ -140,4 +141,86 @@ trait DefaultMongoFormats { } } + implicit val currencyFormat: MongoFormat[Currency] = new MongoFormat[Currency] { + val failMsg = "ISO 4217 code JSON String expected" + + override def toMongoValue(c: Currency): Any = c.getCurrencyCode + override def fromMongoValue(any: Any): Currency = any match { + case s: String => + try { + Currency.getInstance(s) + } catch { + case _: IllegalArgumentException => throw new Exception(failMsg) + } + } + } + + implicit val moneyFormat: MongoFormat[Money] = new MongoFormat[Money] { + import Money._ + + override val fields = Set(CentAmountField, CurrencyCodeField) + + override def toMongoValue(m: Money): Any = { + new BasicBSONObject() + .append(BaseMoney.TypeField, m.`type`) + .append(CurrencyCodeField, currencyFormat.toMongoValue(m.currency)) + .append(CentAmountField, longFormat.toMongoValue(m.centAmount)) + .append(FractionDigitsField, m.currency.getDefaultFractionDigits) + } + + override def fromMongoValue(any: Any): Money = any match { + case dbo: BSONObject => + Money.fromCentAmount( + field[Long](CentAmountField, dbo), + field[Currency](CurrencyCodeField, dbo)) + case other => throw new Exception(s"db object expected but has '${other.getClass.getName}'") + } + } + + implicit val highPrecisionMoneyFormat: MongoFormat[HighPrecisionMoney] = new MongoFormat[HighPrecisionMoney] { + import HighPrecisionMoney._ + + override val fields = Set(PreciseAmountField, CurrencyCodeField, FractionDigitsField) + + override def toMongoValue(m: HighPrecisionMoney): Any = { + new BasicBSONObject() + .append(BaseMoney.TypeField, m.`type`) + .append(CurrencyCodeField, currencyFormat.toMongoValue(m.currency)) + .append(CentAmountField, longFormat.toMongoValue(m.centAmount)) + .append(PreciseAmountField, longFormat.toMongoValue(m.preciseAmountAsLong)) + .append(FractionDigitsField, m.fractionDigits) + } + override def fromMongoValue(any: Any): HighPrecisionMoney = any match { + case dbo: BSONObject => + HighPrecisionMoney.fromPreciseAmount( + field[Long](PreciseAmountField, dbo), + field[Int](FractionDigitsField, dbo), + field[Currency](CurrencyCodeField, dbo), + field[Option[Long]](CentAmountField, dbo) + ).fold(nel => throw new Exception(nel.toList.mkString(", ")), identity) + + case other => throw new Exception(s"db object expected but has '${other.getClass.getName}'") + } + } + + implicit val baseMoneyFormat: MongoFormat[BaseMoney] = new MongoFormat[BaseMoney] { + override def toMongoValue(a: BaseMoney): Any = a match { + case m: Money => moneyFormat.toMongoValue(m) + case m: HighPrecisionMoney => highPrecisionMoneyFormat.toMongoValue(m) + } + override def fromMongoValue(any: Any): BaseMoney = any match { + case dbo: BSONObject => + Option(dbo.get(BaseMoney.TypeField)).map(stringFormat.fromMongoValue) match { + case None => moneyFormat.fromMongoValue(any) + case Some(Money.TypeName) => moneyFormat.fromMongoValue(any) + case Some(HighPrecisionMoney.TypeName) => highPrecisionMoneyFormat.fromMongoValue(any) + case Some(tpe) => throw new Exception(s"Unknown money type '$tpe'. Available types are: '${Money.TypeName}', '${HighPrecisionMoney.TypeName}'.") + } + case other => throw new Exception(s"db object expected but has '${other.getClass.getName}'") + } + } + + + private def field[A](name: String, dbo: BSONObject)(implicit format: MongoFormat[A]): A = + format.fromMongoValue(dbo.get(name)) } diff --git a/mongo/src/test/scala/io/sphere/mongo/format/BaseMoneyMongoFormatTest.scala b/mongo/src/test/scala/io/sphere/mongo/format/BaseMoneyMongoFormatTest.scala new file mode 100644 index 00000000..c95488f3 --- /dev/null +++ b/mongo/src/test/scala/io/sphere/mongo/format/BaseMoneyMongoFormatTest.scala @@ -0,0 +1,97 @@ +package io.sphere.mongo.format + + +import java.util.Currency + +import io.sphere.util.{BaseMoney, HighPrecisionMoney, Money} +import org.scalatest.{Matchers, WordSpec} +import DefaultMongoFormats._ +import io.sphere.mongo.MongoUtils._ +import org.bson.BSONObject +import scala.collection.JavaConverters._ + +class BaseMoneyMongoFormatTest extends WordSpec with Matchers { + + "MongoFormat[BaseMoney]" should { + "be symmetric" in { + val money = Money.EUR(34.56) + val f = MongoFormat[Money] + val dbo = f.toMongoValue(money) + val readMoney = f.fromMongoValue(dbo) + + money should be (readMoney) + } + + "decode with type info" in { + val dbo = dbObj( + "type" -> "centPrecision", + "currencyCode" -> "USD", + "centAmount" -> 3298 + ) + + MongoFormat[BaseMoney].fromMongoValue(dbo) should be (Money.USD(BigDecimal("32.98"))) + } + + "decode without type info" in { + val dbo = dbObj( + "currencyCode" -> "USD", + "centAmount" -> 3298 + ) + + MongoFormat[BaseMoney].fromMongoValue(dbo) should be (Money.USD(BigDecimal("32.98"))) + } + } + + "MongoFormat[HighPrecisionMoney]" should { + "be symmetric" in { + implicit val mode = BigDecimal.RoundingMode.HALF_EVEN + + val money = HighPrecisionMoney.fromDecimalAmount(34.123456, 6, Currency.getInstance("EUR")) + val dbo = MongoFormat[HighPrecisionMoney].toMongoValue(money) + + val decodedMoney = MongoFormat[HighPrecisionMoney].fromMongoValue(dbo) + val decodedBaseMoney = MongoFormat[BaseMoney].fromMongoValue(dbo) + + decodedMoney should equal (money) + decodedBaseMoney should equal (money) + } + + "decode with type info" in { + val dbo = dbObj( + "type" -> "highPrecision", + "currencyCode" -> "USD", + "preciseAmount" -> 42, + "fractionDigits" -> 4 + ) + + MongoFormat[BaseMoney].fromMongoValue(dbo) should be ( + HighPrecisionMoney.USD(BigDecimal("0.0042"), Some(4))) + } + + "decode with centAmount" in { + val dbo = dbObj( + "type" -> "highPrecision", + "currencyCode" -> "USD", + "preciseAmount" -> 42, + "centAmount" -> 1, + "fractionDigits" -> 4 + ) + + val parsed = MongoFormat[BaseMoney].fromMongoValue(dbo) + MongoFormat[BaseMoney].toMongoValue(parsed).asInstanceOf[BSONObject].toMap.asScala should be ( + dbo.toMap.asScala) + } + + "validate data when decoded from JSON" in { + val dbo = dbObj( + "type" -> "highPrecision", + "currencyCode" -> "USD", + "preciseAmount" -> 42, + "fractionDigits" -> 1 + ) + + an[Exception] shouldBe thrownBy (MongoFormat[BaseMoney].fromMongoValue(dbo)) + } + } + +} diff --git a/util/src/main/scala/Money.scala b/util/src/main/scala/Money.scala index 4530117f..81ae14d2 100644 --- a/util/src/main/scala/Money.scala +++ b/util/src/main/scala/Money.scala @@ -56,6 +56,8 @@ sealed trait BaseMoney { } object BaseMoney { + val TypeField: String = "type" + def requireSameCurrency(m1: BaseMoney, m2: BaseMoney): Unit = require(m1.currency eq m2.currency, s"${m1.currency} != ${m2.currency}") @@ -241,7 +243,10 @@ object Money { def GBP(amount: BigDecimal): Money = decimalAmountWithCurrencyAndHalfEvenRounding(amount, "GBP") def JPY(amount: BigDecimal): Money = decimalAmountWithCurrencyAndHalfEvenRounding(amount, "JPY") - val TypeName = "centPrecision" + val CurrencyCodeField: String = "currencyCode" + val CentAmountField: String = "centAmount" + val FractionDigitsField: String = "fractionDigits" + val TypeName: String = "centPrecision" def fromDecimalAmount(amount: BigDecimal, currency: Currency)(implicit mode: RoundingMode) = Money(amount.setScale(currency.getDefaultFractionDigits, mode), currency) @@ -426,7 +431,12 @@ object HighPrecisionMoney { def GBP(amount: BigDecimal, fractionDigits: Option[Int] = None) = simpleValueMeantToBeUsedOnlyInTests(amount, "GBP", fractionDigits) def JPY(amount: BigDecimal, fractionDigits: Option[Int] = None) = simpleValueMeantToBeUsedOnlyInTests(amount, "JPY", fractionDigits) - val TypeName = "highPrecision" + val CurrencyCodeField: String = "currencyCode" + val CentAmountField: String = "centAmount" + val PreciseAmountField: String = "preciseAmount" + val FractionDigitsField: String = "fractionDigits" + + val TypeName: String = "highPrecision" val MaxFractionDigits = 20 private def simpleValueMeantToBeUsedOnlyInTests(amount: BigDecimal, currencyCode: String, fractionDigits: Option[Int] = None): HighPrecisionMoney = { From f88e868483c68dceffc729b748bd2ec27281ec50 Mon Sep 17 00:00:00 2001 From: Yann Simon Date: Fri, 1 Mar 2019 13:29:41 +0100 Subject: [PATCH 2/2] add failing currency in error msg --- json/src/main/scala/io/sphere/json/FromJSON.scala | 6 ++++-- .../scala/io/sphere/mongo/format/DefaultMongoFormats.scala | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/json/src/main/scala/io/sphere/json/FromJSON.scala b/json/src/main/scala/io/sphere/json/FromJSON.scala index 1f8109ef..4f0b166e 100644 --- a/json/src/main/scala/io/sphere/json/FromJSON.scala +++ b/json/src/main/scala/io/sphere/json/FromJSON.scala @@ -220,13 +220,15 @@ object FromJSON { } implicit val currencyReader: FromJSON[Currency] = new FromJSON[Currency] { - val failMsg = "ISO 4217 code JSON String expected" + val failMsg = "ISO 4217 code JSON String expected." + def failMsgFor(input: String) = s"Currency '$input' not valid as ISO 4217 code." + def read(jval: JValue): JValidation[Currency] = jval match { case JString(s) => try { Valid(Currency.getInstance(s)) } catch { - case e: IllegalArgumentException => fail(failMsg) + case e: IllegalArgumentException => fail(failMsgFor(s)) } case _ => fail(failMsg) } diff --git a/mongo/src/main/scala/io/sphere/mongo/format/DefaultMongoFormats.scala b/mongo/src/main/scala/io/sphere/mongo/format/DefaultMongoFormats.scala index cade6ff2..e8be9bf8 100644 --- a/mongo/src/main/scala/io/sphere/mongo/format/DefaultMongoFormats.scala +++ b/mongo/src/main/scala/io/sphere/mongo/format/DefaultMongoFormats.scala @@ -142,7 +142,8 @@ trait DefaultMongoFormats { } implicit val currencyFormat: MongoFormat[Currency] = new MongoFormat[Currency] { - val failMsg = "ISO 4217 code JSON String expected" + val failMsg = "ISO 4217 code JSON String expected." + def failMsgFor(input: String) = s"Currency '$input' not valid as ISO 4217 code." override def toMongoValue(c: Currency): Any = c.getCurrencyCode override def fromMongoValue(any: Any): Currency = any match { @@ -150,8 +151,9 @@ trait DefaultMongoFormats { try { Currency.getInstance(s) } catch { - case _: IllegalArgumentException => throw new Exception(failMsg) + case _: IllegalArgumentException => throw new Exception(failMsgFor(s)) } + case _ => throw new Exception(failMsg) } }