Introducing: "SwiftCurrency"

SwiftCurrency is a Swift package providing type-safety and common algorithms for working with money. It primarily focuses on the ISO 4217 standard.

We just tagged 0.6.0 today, and are looking to support this in the wider ecosystem. Try it out and let us know what you think!

For a deeper dive on how to use the library, check out the usage doc.

Code sample:

import Currency
import Foundation

let dollars = USD(30.01)
print(dollars)
// 30.01 USD
print(dollars * 2)
// 60.02 USD
print(dollars.distributedEvenly(intoParts: 6))
// [USD(1.68), USD(1.68), USD(1.68), USD(1.67), USD(1.67), USD(1.67)]

let pounds = GBP(109.23)
print(dollars + pounds)
// compile error

let jpy: JPY = 399
print("The total price is \(jpy.localizedString()).")
// "The total price is ¥399.", assuming `Foundation.Locale.current` is "en_US"

let euro = EUR(29.09)
print("Der Gesamtpreis beträgt \(localize: euro, for: .init(identifier: "de_DE")).")
// "Der Gesamtpreis beträgt 29,09 €."
16 Likes

Does this do anything to help with user input of currency, perhaps through formatting, or network handling of currency values through Codable?

Not currently, but these are great features I'd like to see supported by SwiftCurrency!

For Codable, I wasn't sure what was the best way to support it and was going to wait for some input from others before taking a crack at it.

For user input, what would you like to see supported?

In both cases I'm mainly concerned about proper handling of precision to ensure no rounding or other issues. Especially in the user input case, I've found Decimal's current String parsing to be buggy by producing rounded or imprecise values. Being able to precisely roundtrip through String would be great.

2 Likes

Yes, the only way to JSON encode currency values is as String. There is no way around it. One thing I am also curious about (sry that I didn't dig into the code myself): Are decimal places preserved, or are they really cut off after the minor unit?

It's incredibly important that while being displayed with 2 or 3 places, any kind of calculation preserves anything after those visual decimal places. Further calculations really need that precision. Only after explicit command, must decimal places be truncated.

But this library looks incredibly nice! Really awesome work Nathan!

1 Like

I created an issue to discuss Codable conformance, as I'm unsure what the desired output/input should be. Related to this is probably conformance to LosslessStringConvertible.

Regarding rounding, SwiftCurrency's (current) design philosophy is to represent values as physical currency. So this means what is expressible in the currency's actual minor unit.

If you need the extra precision, such as for commodity and security prices (gasoline in the US always uses 3 decimal values, rather than the standard 2), there are two options:

  1. Create a custom currency with additional minorUnits precision
  2. Use the amount computed property to do all your calculations, before creating a currency representation at the end:
    let basePrice = USD(30.01)
    let discount = basePrice.amount * 0.15
    // discount == Decimal(4.5015)
    let total = USD(amount: basePrice.amount - discount)
    // basePrice.amount - discount == Decimal(25.5085)
    // total == USD(25.51)
    
2 Likes

With this argument though, no business operating with currency as values can use this library.
I mean if you calculate taxes, rebates, or splitting and summing up amounts etc… you always need more accuracy than what coins have. That decision I don't understand.

1 Like

Hello @Mordil,

This is a very interesting package, that I'll keep under my pillow until I find a use case for it.

May I say why I can't use it right now in production?

  1. Serialization of amounts is very important. I know two important ways to serialize them: as string-encoded decimal numbers, and as integer (with a chosen multiplier which is a power of 10, not always the minor unit). Integers are very important, because they allow databases that do not support decimal numbers to perform exact computations (read: the omnipresent SQLite). It should be possible to imagine the typical boiler plate application developers would have to write when coding and decoding, and help them. In this use case, the standard Codable protocol should only be considered as a data point, not as the alpha and omega of serialization (IMHO).

  2. Rates are very important too. Apps that deal with money sometimes have to deal with rates. 2%, 3.20%, etc. Computations with rates comes with as many traps as computations with amounts. And rates have to be serialized as well.

  3. Physical money is important, but it should be easy to deal with values that have more precision than the physical minor unit. Boundaries should be trivial to deal with. Quick and dirty sample code:

    let gasAmount: USGas = ...
    
    if let usdAmount = USD(exactly: gasAmount) {
        // conversion successful (3.270 fits in USD)
    } else {
        // conversion failed (3.279 won't fit in USD)
    }
    
    // Always succeeds
    let usdAmount = USD(truncatingIfNeeded: gasAmount)
    
    // Always succeeds (we may also need rounding up)
    let usdAmount = USD(rounding: gasAmount)
    
    // Should not compile, or at least trap at runtime
    let euroAmount: EUR = ...
    USD(exactly: euroAmount)
    USD(truncatingIfNeeded: euroAmount)
    USD(rounding: euroAmount)
    
3 Likes

@gwendal.roue For your consideration, you might check out Money as an alternative. It addresses your first and third points and provides guidance for how you might handle #2 (depending on your particular circumstances).

1 Like

Thanks @mattt for the hint! Stabilizing money-related code across several - unrelated - projects is a long process :sweat_smile:

I've had this RFC open for awhile to discuss the pros/cons of the value precision for awhile now. We should split the discussion into that thread to really hone in on the best outcome.

I appreciate the concern, as that RFC clearly shows this is a worrisome and contentious topic, but this feedback is not specific enough to be helpful. We are using this library in our point-of-sale app for manipulating different currency values and later doing transactions (both digital and physical).

Could you describe a situation where needing the extra precision is necessary for the absolute value of a currency?

As I said, the design philosophy has been that the representation of currency's value in this library matches what is actually representable of that currency.

In real terms, how do I give someone 0.001 USD? In a real transaction, this isn't possible. There is no concept for less than 0.01 USD.

It's only possible digitally, and is up to the independent service to figure out how both support and handle that case.

So, with that in mind, I'm having a hard time seeing this hypothetical invoice when trying to deal with the currency. How do you accurately conduct transactions of a fraction of a currency's unit?

item1      $1.90
item2      $2.35
--
Subtotal   $4.25
--
Tax @ 9%   $0.3825 // instead of $0.38
--
Total      $4.6325 // instead of $0.63

when working with the rates, to calculate the $0.3825, you have access to the subtotal.amount property, which is a Foundation.Decimal, that will give you the above value of 0.3825, which can then be stored in a USD instance as USD(0.38).


@gwendal.roue Thanks for the detailed feedback.

  1. This is an interesting discussion to be had, outside of just Codable support - would you be willing to file an issue regarding some hypothetical CurrencySerializer, as that sounds a lot like what you're referring to.
  2. As I've pointed out, there is the amount property that is Foundation.Decimal that gives you the opportunity to work with rates and still maintain extra precision before storing it as a representation of the currency value. With regards to serializing of rates, I'm not seeing how that would fall within the scope of this library.
    You mention traps with rates, would you be willing to share an example or two?
  3. Is your sample code an example of problems with the current design, or problems that future code would ideally solve?

@mattt Thanks for mentioning Flight-School/Money. I took inspiration from it, as well as danthorpe/Money. I made sure to credit both in the project's NOTICE.txt attribution file.

Whenever you do any kind of math with currencies, you benefit from doing the rounding at the final stage. If you do it immediately, you escalate the rounding error downstream. A type with automatic rounding is hiding this problem. A difference of 1 cent in the end might not have an impact at a register where a client pays, but if you do your taxes and have thousands of positions, it might make a difference.

And if you calculate a price-per-day and people book 14 days, you already have multiplied any error by 14. If your original price got out of some calculation (e.g. the supplier's price already comes out of their system at higher precision - which oddly really happens), or if the commission is a percentage, you immediately get into precision trouble. If you sum those up you will definitely have discrepancy fast.

2 Likes

I swear a unit test covering this was written, but it turns out there wasn't.

I've written a unit test that covers the scenario you've described, and it does indeed fail by 0.01.

Rounding Unit Test
func testExampleHotelBooking() {
    /*
                        Decimal      |    USD (rounding at each step)
     Base Price:        199.98       |                    199.98
     ----
     6% Discount:        11.9988     |     11.9988  =>     12.00
     Running Total:     187.9812     |                    187.98
     ----
     9% Tax:             16.918308   |     16.9182  =>     16.92
     Running Total:     204.899508   |                    204.90
     ----
     Franchise fee:       5.68       |                      5.68
     Running Total:     210.579508   |                    210.58
     ----
     Total (7 days)   1,474.056556   |                  1,474.06
     ----
     10% Commission     147.4056556  |    147.406   =>    147.41
     Grand Total      1,621.4622116  |  1,621.466   =>  1,621.47
     */
    let roomDailyRate = USD(199.98)
    let discountRate = Decimal(0.06)
    let taxRate = Decimal(0.09)
    let flatFranchiseFee = USD(5.68)

    var runningDailyTotal = roomDailyRate

    // apply discount
    let discount = USD(amount: roomDailyRate.amount * discountRate)!
    XCTAssertEqual(discount, 12)
    runningDailyTotal -= discount
    XCTAssertEqual(runningDailyTotal, 187.98)

    // apply taxes
    let taxes = USD(amount: runningDailyTotal.amount * taxRate)!
    XCTAssertEqual(taxes, 16.92)
    runningDailyTotal += taxes
    XCTAssertEqual(runningDailyTotal, 204.90)

    // apply flat fee
    runningDailyTotal += flatFranchiseFee
    XCTAssertEqual(runningDailyTotal, 210.58)

    // calculate week total
    let weekRateTotal = USD(amount: runningDailyTotal.amount * 7)!
    XCTAssertEqual(weekRateTotal, 1_474.06)

    // calculate commission
    let commission = USD(amount: weekRateTotal.amount * 0.10)!
    XCTAssertEqual(commission, 147.41)

    let totalPrice = weekRateTotal + commission
    XCTAssertEqual(totalPrice, 1_621.47)

    // Decimal validation
    let expectedResult: Decimal = {
      let basePrice = roomDailyRate.amount
      let discount = basePrice * 0.06
      let tax = (basePrice - discount) * 0.09
      let dayPrice = basePrice - discount + tax + 5.68
      let weekPrice = dayPrice * 7
      let commission = weekPrice * 0.10
      return weekPrice + commission
    }()
    XCTAssertEqual(expectedResult, 1_621.4622116)
    XCTAssertEqual(USD(amount: expectedResult), totalPrice)
  }

Because of this, I will be reverting the use of the integer value as the base storage and stick with Foundation.Decimal.

1 Like

I used to work at a place that used a point of sale that only supported two decimal places.

We wasted hours with calculators trying to make the digital purchase orders match the paper sent by vendors.

The vendors would send stuff in wholesale units (2 or more items). So when the wholesale packs were split into retail units, the cost per item was now a half penny too low or too high.

In the end we had to manually tally every difference and fill it into the purchase order adjustment field.

It is very disheartening when you just calculated through a 20 page list of items only to find you are off by 3 cents.

1 Like