Skip to content

Commit

Permalink
Merge pull request #79 from sphereio/money_mongo_formats
Browse files Browse the repository at this point in the history
MongoFormat for BaseMoney
  • Loading branch information
yanns authored Mar 1, 2019
2 parents e25936e + f88e868 commit 6a4531e
Show file tree
Hide file tree
Showing 5 changed files with 224 additions and 23 deletions.
26 changes: 16 additions & 10 deletions json/src/main/scala/io/sphere/json/FromJSON.scala
Original file line number Diff line number Diff line change
Expand Up @@ -168,29 +168,33 @@ 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.")
}
}

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(_)))
Expand All @@ -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)
Expand All @@ -216,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)
}
Expand Down
21 changes: 12 additions & 9 deletions json/src/main/scala/io/sphere/json/ToJSON.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -140,4 +141,88 @@ trait DefaultMongoFormats {
}
}

implicit val currencyFormat: MongoFormat[Currency] = new MongoFormat[Currency] {
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 {
case s: String =>
try {
Currency.getInstance(s)
} catch {
case _: IllegalArgumentException => throw new Exception(failMsgFor(s))
}
case _ => 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))
}
Original file line number Diff line number Diff line change
@@ -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))
}
}

}
14 changes: 12 additions & 2 deletions util/src/main/scala/Money.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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}")

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 = {
Expand Down

0 comments on commit 6a4531e

Please sign in to comment.