4

NumberFormatter makes it quite easy to format currencies when presenting values on screen:

let decimal = Decimal(25.99)
let decimalNumberFormatter = NumberFormatter()
decimalNumberFormatter.numberStyle = .currencyAccounting
let output = decimalNumberFormatter.string(for: decimal)
// output = "$25.99"

The above code works well both for any Decimal or Double values. The amount of decimal digits always matches that of the locale being used.

Turns our that serializing a floating point currency value to JSON is not that trivial.

Having the following serializing method (mind the force unwraps):

func serialize(prices: Any...) {
    let data = try! JSONSerialization.data(withJSONObject: ["value": prices], options: [])
    let string = String(data: data, encoding: .utf8)!
    print(string)
}

We can then call it with different values and types. Double, Decimal and NSDecimalNumber (which should be bridged from Swift's Decimal) fail to properly render the value in some cases.

serialize(prices: 125.99, 16.42, 88.56, 88.57, 0.1 + 0.2)
// {"value":[125.99,16.42,88.56,88.56999999999999,0.3]}

serialize(prices: Decimal(125.99), Decimal(16.42), Decimal(88.56), Decimal(88.57), Decimal(0.1) + Decimal(0.2))
// {"value":[125.98999999999997952,16.420000000000004096,88.56,88.57,0.3]}

serialize(prices: NSDecimalNumber(value: 125.99), NSDecimalNumber(value: 16.42), NSDecimalNumber(value: 88.56), NSDecimalNumber(value: 88.57), NSDecimalNumber(value: 0.1).adding(NSDecimalNumber(value: 0.2)))
// {"value":[125.98999999999997952,16.420000000000004096,88.56,88.57,0.3]}

I'm not looking to serialize numbers as currencies (no need for currency symbol, integers (5) or single decimal position (0.3) are fine). However I'm looking for a solution where the serialized output contains no more than the number of decimals allowed by a given currency (locale).

This is, is there any way to limit or specify the number of decimals to be used when serializing floating point values to JSON?

Update #1: Tested with more data types, surprisingly seems like both Float and Float32 work well for two-decimal currencies. Float64 fails as Double (probably they are an alias of the same type).

serialize(prices: Float(125.99), Float(16.42), Float(88.56), Float(88.57), Float(0.1) + Float(0.2))
// {"value":[125.99,16.42,88.56,88.57,0.3]}

serialize(prices: Float32(125.99), Float32(16.42), Float32(88.56), Float32(88.57), Float32(0.1) + Float32(0.2))
// {"value":[125.99,16.42,88.56,88.57,0.3]}

serialize(prices: Float64(125.99), Float64(16.42), Float64(88.56), Float64(88.57), Float64(0.1) + Float64(0.2))
// {"value":[125.99,16.42,88.56,88.56999999999999,0.3]}

Hard to know if they work well in all cases, though. Float80 fails to serialize with a _NSJSONWriter exception.

Eneko Alonso
  • 18,884
  • 9
  • 62
  • 84
  • About this line: `I'm looking for a solution where the serialized output contains no more than the number of decimals allowed by a given currency`, does that mean you want a non-reversible encoding to JSON? E.g. if an amount, let's say `$5.75` was encoded in a currency that only required one decimal point of precision, it should be encoded as `5.7` and the `$0.05` would be lost. – paulvs Jul 31 '17 at 16:33
  • Ideally, it would rely on the `maximumFractionDigits` allowed for the current locale currency, so no decimals would be lost. – Eneko Alonso Jul 31 '17 at 16:47
  • Any thoughts on how this could work in the opposite way i.e. a Decimal with value `10` is serialised as such. Could this apporach be used to serialise it as `10.0`? – JoGoFo Aug 09 '18 at 23:02

3 Answers3

14

After doing some research in this matter, a coworker found that rounding the values specifying a behavior using NSDecimalNumberHandler solves the JSON serialization issue.

fileprivate let currencyBehavior = NSDecimalNumberHandler(roundingMode: .bankers, scale: 2, raiseOnExactness: false, raiseOnOverflow: false, raiseOnUnderflow: false, raiseOnDivideByZero: true)

extension Decimal {
    var roundedCurrency: Decimal {
        return (self as NSDecimalNumber).rounding(accordingToBehavior: currencyBehavior) as Decimal
    }
}

Following the example code from the post, we get the desired output:

serialize(prices: Decimal(125.99).roundedCurrency, Decimal(16.42).roundedCurrency, Decimal(88.56).roundedCurrency, Decimal(88.57).roundedCurrency, (Decimal(0.1) + Decimal(0.2)).roundedCurrency)
// {"value":[125.99,16.42,88.56,88.57,0.3]}

It works! Ran a test for 10000 values (from 0.0 to 99.99) and found no issues.

If needed, the scale can be adjusted to the number of decimals from the current locale:

var currencyFormatter = NumberFormatter()
currencyFormatter.numberStyle = .currencyAccounting
let scale = currencyFormatter.maximumFractionDigits
// scale == 2
Eneko Alonso
  • 18,884
  • 9
  • 62
  • 84
0

The problem is that you are using Any as a variadic input parameter to the function instead of using a generic function/overloading the function. This way the exact type information is masked by upcasting to Any.

You have several methods to solve this issue:

  1. Keep the current implementation with Any as the type of the input parameters, but conditional downcast your value inside serialize before printing them and use a NumberFormatter() for the printing.
  2. Change the implementation of serialize to be a generic function.
  3. Implement 3 overloaded versions of serialize, each accepting a different number type and working with the exact types.

If you need your JSON to contain your prices in a certain formatting, you should serialise the output of the NumberFormatter instead of the numbers themselves.

Dávid Pásztor
  • 51,403
  • 9
  • 85
  • 116
  • `JSONSerialization` takes `Any` for a dictionary to serialize. It does not matter what type you use for the dictionary values (`[String: Double]`, `[String: Decimal]`...), the float point precision issue persists. See https://developer.apple.com/documentation/foundation/jsonserialization/1413636-data – Eneko Alonso Jul 31 '17 at 16:45
  • As I said in my last sentence, if you need the `JSON` itself to contain numbers with a fixed format, you have to serialise string representation of the numbers instead of the numbers themselves. – Dávid Pásztor Jul 31 '17 at 16:48
-1

A strategy for encoding currency amounts is to convert the amount to an integer by multiplying the value by a multiplication factor. The multiplication factor in this case is given by the 10 raised to the power of the currency's maximumFractionDigits (e.g. 10^2 for currencies that use two digits in the fractional part).

Keep in mind that this approach is only suitable for storing amounts that are ready to be shown to the user. See here for details.

func serialize(prices: Double..., locale: Locale) {

    let formatter = NumberFormatter()
    formatter.locale = locale
    formatter.numberStyle = .currencyAccounting

    // We multiply the amount by as many orders of 
    // magnitude are needed to ensure it is an integer.
    // In your implementation, you should store the value of
    // maximumFractionDigits along with the amount to ensure
    // you can always recover the correct value.
    let items = prices.map {
        $0 * pow(10, Double(formatter.maximumFractionDigits))
    }

    let data = try! JSONSerialization.data(withJSONObject: ["value": items], options: [])
    let string = String(data: data, encoding: .utf8)!
    print(string)
}

print(serialize(prices: 125.99, 16.42, 88.56, 88.57, 0.1 + 0.2, locale: Locale(identifier: "en_AU")))

Prints:

{"value":[12599,1642,8856,8857,30]}
paulvs
  • 11,963
  • 3
  • 41
  • 66
  • Agreed, using integers has been a good solution for many years. However, it requires the API receiving the JSON to expect integers too. Currently the API I am working with expect actual amounts, and does not have a way to specify the multiplication factor. – Eneko Alonso Jul 31 '17 at 17:03