package safemoney
Install
Dune Dependency
Authors
Maintainers
Sources
md5=7c6fa3ecf4800e68fea024e6a50aed06
sha512=a30c2b7f08f2bbd0032ea3c14df34781ac00041dca99d0e82ad34afbbc18eb5c07cc30a67d658e3a0225c1efdb1486dd73393a13a59f0e3ee19fee92ce67b089
Description
A type safe money manipulation library
Published: 15 Sep 2023
README
SAFEMONEY
A type safe money manipulation library for ocaml, currently supporting user-defined currency types as well as all ISO4217 currency codes and major crypto currencies.
It is well known that using float numbers for money calculation is inherently lossy and should be avoided at all cost. A classic example shows that 0.21 + 0.32 could possibly lead to 0.530003 which extra money is created out of thin air. Money calculation without context is also considered error prone (e.g. the accidental USD $10. + AUD $10. = ? $20). Also there are a number of float representations and operations do not make sense for money calculation (e.g. accidentally operating on infinity values or using functions like Float.atanh).
On the other hand money calculations using checked rational(quotient) and discrete(integer) numbers with properly established context are considered to be safe. Some of the core types of this library are based on rational and discrete numbers. Back to the previous example of 0.21 + 0.32, we can substitute using rational numbers 21/100 + 32/100 = 53/100 and clearly nothing is lost or created arbitrarily. Alternatively if we were to operate on the scale of "cents" we can then write in integers 21 + 32 = 53 which achieves the same result. This library aims to establish and uphold safety contract by providing contexts in which the calculations take place, the following examples will not compile(see user manual for more examples):
let open Quotient in
let qv1 = make_qv ("AUD", make_q "30/100") in <--- AUD
+
let qv2 = make_qv ("GBP", make_q "20/100") in <--- GBP
=
( + ) qv1 qv2 Error
let open Discrete in
let scale1 = Scale.make_scale "AUD" "dollar" (make_q "1/1") in <--- Unit in dollar
+
let scale1 = Scale.make_scale "AUD" "cent" (make_q "100/1") in <--- Unit in cents
let dv1 = make_dv (scale1, make_z "200") in
let dv2 = make_dv (scale2, make_z "100") in =
( + ) dv1 dv2 Error
Things considered to be unsafe:
For any intermediary calculations using float number
For any intermediary calculations using nonsensical functions and number representations
For any intermediary calculations without establishing context, namely various currency/scale types
Things considered to be maybe unsafe:
Returning(in applicative/monadic sense) float number to a safe context for further operations
That is if the float number itself violates the safety contract mentioned above, then unsafe
But otherwise safe to use and that the burden of proof is with the user
Things consider safe:
Finalising(sealing) the rational/discrete number to float with rounding strategy as the final step of calculation. The burden of proof is with the user not to use the sealed float number for anything unsafe ops mentioned above
User Manual
Walkthrough core modules and types
Qv and Zv
The core types for building rational and integer numbers are Qv and Zv, which are wrappers for zarith Q and Z types respectively. Two convenience functions make_q and make_z are provided for building these values, but otherwise can be built via the make function in each module:
open Safemoney
let qv = make_q "123/45" in ...
let zv = make_z "12345" in ...
Quotient
The Quotient type provides a context for rational number operations, taking a string of currency name and a Qv value:
let open Quotient in
let qv1 = make_qv ("AUD", make_q "30/100") in
let qv2 = make_qv ("AUD", make_q "20/100") in
( + ) qv1 qv2
Discrete
The Discrete type provides a context for integer number operations, taking a Scale type and a Zv value. The Scale type takes a currency name, an unit name and a scale ratio which denote a named division with respect to the unit account of the currency, e.g. for USD the subdivision of 100 "cent" make up to 1 "dollar" of unit account, hence the ratio is 100/1 which is represented by a Qv value:
let open Discrete in
let scale = Scale.make_scale "USD" "cent" (make_q "100/1") in
let dv1 = make_dv (scale, make_z "200") in
let dv2 = make_dv (scale, make_z "100") in
( - ) dv1 dv2
Exchange
The Exchange type provides a context for currency exchange mechanism, taking a source currency, destination currency and exchange rate of Qv value:
let open Exchange in
let aud_to_nzd = make_xchg ~src: "AUD" ~dst: "NZD" (make_q "4908/4503") in ...
To compose exchange rate, e.g. from GBP to NZD via AUD:
let open Exchange in
let gbp_to_aud = make_xchg ~src: "GBP" ~dst: "AUD" (make_q "8872/4503") in
let aud_to_nzd = make_xchg ~src: "AUD" ~dst: "NZD" (make_q "4908/4503") in
let gbp_to_nzd = ( **> ) gbp_to_aud aud_to_nzd in ...
Custom
The Custom type provides a signature for user defined currency types, allowing items such as currency code, description of the currency, an optional hashmap of unit scales and Qv/Zv initialiser to be specified. Obviously this is an opinionated approach and the Predefined module relies on this signature, but users could optionally come up with their own signatures to cater for specific needs. Let's define a new module for a currency called CamelCoin™ by implementing Custom signature:
module CAMELCOIN : Custom = struct
let symbol = "CMC"
let description = "Camel Coin🐫"
let units =
let table = Hashtbl.create (module String) in
let hoof = Discrete.Scale.make_scale symbol "hoof" (Utils.make_q "1/1") in
let hump = Discrete.Scale.make_scale symbol "hump" (Utils.make_q "100/1") in
Hashtbl.set table ~key: "hoof" ~data: hoof;
Hashtbl.set table ~key: "hump" ~data: hump;
Some table
let make_qv qv = Quotient.make_qv (symbol, qv)
let make_dv unit dv =
match units with
| Some tbl -> let s = Option.value_exn ~message: "Error retriving non-existent scale" @@ Hashtbl.find tbl unit in Some (Discrete.make_dv (s, dv))
| None -> None
end
let open Quotient in
let qv1 = CAMELCOIN.make_qv (make_q "30/100") in
let qv2 = CAMELCOIN.make_qv (make_q "20/100") in
let dv1 = Core.Option.value_exn (CAMELCOIN.make_dv "cent" (make_z "200")) in
let dv2 = Core.Option.value_exn (CAMELCOIN.make_dv "cent" (make_z "100")) in
( - ) qv1 qv2;
( + ) dv1 dv2;
Predefined
The Predefined module includes readily made ISO4217 currencies and major crypto currencies. See usage in Custom.
Sealing Operations
Practically in real life when calculations are DONE on these safe types we might want to convert them to the float representations along with a conversion strategies, effectively sealing the operations/presenting the final result, and promise not to use it further. The Utils module provides such sealing functions and their effective signatures are:
seal_quotient -> (val seal_quotient: printing_conf:printing_conf -> qv:Safemoney.Quotient.t -> string)
seal_discrete -> (val seal_discrete: printing_conf:printing_conf -> dv:Safemoney.Discrete.t -> string)
seal_exchange -> (val seal_exchange: printing_conf:printing_conf -> xchg:Safemoney.Exchange.t -> string)
seal_scale -> (val seal_scale: printing_conf:printing_conf -> scale:Safemoney.Discrete.Scale.t -> string)
The printing_conf specifies how the the final float number should be presented, by providing a number Separator, whether to print a "+" sign, number of decimal places to show and a rounding strategy. e.g to seal a Quotient value:
let open Utils in
let open Safemoney in
//We make a value on the fly but usually it is the result of a chain of safe Qv type calculations
let qv = make_qv ("USD", make_q "1234567/7") in
//Using a premade separator "sep_dot_comma" in Utils module
let printing_conf = { separator = sep_dot_comma; plus_sign = true; num_of_digits = Uint8.of_int 4; rounding = Truncate } in
seal_quotient ~printing_conf: printing_conf ~qv: qv
would print "+176,366.7142"
Note the sealed result is a String to discourage further usage
Unsafe
This library does provide escape hatches to allow float values from unsafe part of the codebase to be returned into the safe types, if you cannot establish the chain of proof for the float safety contract then use them at your own peril. The Utils module provides such functions:
unsafe_float_to_quotient
unsafe_float_to_discrete
unsafe_float_to_exchange
unsafe_float_to_scale
Note the float value is taken as String to encourage "finalisation" on previously unsafe float. To guide the functions on how to correctly recognise the separator the correct one should be provided or parser error is thrown.
TODOs
Toplevel Printer
Preprocessor
License
This project is licensed under the MIT license.
Contribution
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in safemoney by you, shall be licensed as MIT, without any additional terms or conditions.
Dependencies (12)
-
ppx_deriving_yojson
< "3.8.0"
- ppx_yojson_conv
- ppx_deriving
- ppx_jane
- yojson
- re2
-
angstrom
>= "0.14.0"
- stdint
-
zarith
>= "1.6"
-
dune
>= "3.0"
- core
-
ocaml
>= "4.14.0"
Dev Dependencies (1)
-
odoc
with-doc
Used by
None
Conflicts
None