Skip to content

Commit

Permalink
money: improve documentation and test coverage
Browse files Browse the repository at this point in the history
  • Loading branch information
eapenkin authored Dec 18, 2023
1 parent 0ac5903 commit b224928
Show file tree
Hide file tree
Showing 12 changed files with 880 additions and 542 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## [0.2.1] - 2023-12-18

### Changed

- Improved examples and documentation.
- Improved test coverage.

## [0.2.0] - 2023-12-12

### Added
Expand All @@ -25,6 +32,8 @@
- `ExchangeRate.Trim`.
- `ExchangeRate.IsPos`,
- `ExchangeRate.Sign`,
- `ExchangeRate.MinScale`,
- `ExchangeRate.Quantize`,
- Implemented `NullCurrency` type.

### Changed
Expand Down
82 changes: 78 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,23 @@
[![versionb]][version]
[![awesomeb]][awesome]

Package money implements immutable monetary amounts for Go.
Package money implements immutable monetary amounts and exchange rates for Go.

## Features

- **Optimized Performance** - Utilizes `uint64` for coefficients, reducing heap
allocations and memory consumption.
- **High Precision** - Supports 19 digits of precision, which allows representation
of amounts between -99,999,999,999,999,999.99 and 99,999,999,999,999,999.99 inclusive.
- **Immutability** - Once an amount or exchange rate is set, it remains unchanged.
This immutability ensures safe concurrent access across goroutines.
- **Banker's Rounding** - Methods use half-to-even rounding, also known as "banker's rounding",
which minimizes cumulative rounding errors commonly seen in financial calculations.
- **No Panics** - All methods are designed to be panic-free.
Instead of potentially crashing your application, they return errors for issues
such as overflow, division by zero, or currency mismatch.
- **Correctness** - Fuzz testing is used to [cross-validate] arithmetic operations
against the [cockroachdb] and [shopspring] decimal packages.

## Getting started

Expand All @@ -35,22 +51,21 @@ import (
)

func main() {
x, _ := decimal.New(2, 0) // x = 2

// Constructors
a, _ := money.NewAmount("USD", 8, 0) // a = USD 8.00
b, _ := money.ParseAmount("USD", "12.5") // b = USD 12.50
c, _ := money.NewAmountFromFloat64("USD", 2.567) // c = USD 2.567
d, _ := money.NewAmountFromInt64("USD", 7, 896, 3) // d = USD 7.896
r, _ := money.NewExchRate("USD", "EUR", 9, 1) // r = USD/EUR 0.9
x, _ := decimal.New(2, 0) // x = 2

// Operations
fmt.Println(a.Add(b)) // USD 8.00 + USD 12.50
fmt.Println(a.Sub(b)) // USD 8.00 - USD 12.50

fmt.Println(a.Mul(x)) // USD 8.00 * 2
fmt.Println(a.FMA(x, b)) // USD 8.00 * 2 + USD 12.50
fmt.Println(r.Conv(a)) // USD/EUR 0.9 * USD 8.00
fmt.Println(r.Conv(a)) // USD 8.00 * USD/EUR 0.9

fmt.Println(a.Quo(x)) // USD 8.00 / 2
fmt.Println(a.QuoRem(x)) // USD 8.00 div 2, USD 8.00 mod 2
Expand Down Expand Up @@ -82,6 +97,59 @@ func main() {
For detailed documentation and additional examples, visit the package
[documentation](https://pkg.go.dev/github.com/govalues/money#pkg-examples).

## Comparison

Comparison with other popular packages:

| Feature | govalues | [rhymond] v1.0.10 | [bojanz] v1.2.1 |
| ------------------------------- | -------------- | ----------------- | --------------- |
| Speed | High | Medium | Medium |
| Numeric Representation | Floating Point | Fixed Point | Floating Point |
| Precision | 19 digits | 18 digits | 39 digits |
| Default Rounding | Half to even | Not supported | Half up |
| Overflow Control | Yes | No[^wraparound] | Yes |
| Support for Division | Yes | No | Yes |
| Support for Currency Conversion | Yes | No | Yes |

[^wraparound] [rhymond] does not detect overflow and returns an invalid result.
For example, 92,233,720,368,547,758.07 + 0.01 results in -92,233,720,368,547,758.08.

### Benchmarks

```text
goos: linux
goarch: amd64
pkg: github.com/govalues/money-tests
cpu: AMD Ryzen 7 3700C with Radeon Vega Mobile Gfx
```

| Test Case | Expression | govalues | [rhymond] v1.0.10 | [bojanz] v1.2.1 | govalues vs rhymond | govalues vs bojanz |
| ----------- | ------------------------- | -------: | ----------------: | --------------: | ------------------: | -----------------: |
| Add | USD 2.00 + USD 3.00 | 22.95n | 218.30n | 144.10n | +851.41% | +528.02% |
| Mul | USD 2.00 * 3 | 21.80n | 133.40n | 239.60n | +511.79% | +998.83% |
| QuoFinite | USD 2.00 / 4 | 80.12n | n/a[^nodiv] | 468.05n | n/a | +484.19% |
| QuoInfinite | USD 2.00 / 3 | 602.1n | n/a[^nodiv] | 512.4n | n/a | -14.91% |
| Split | USD 2.00 into 10 parts | 374.9n | 897.0n | n/a[^nosplit] | +139.28% | n/a |
| Conv | USD 2.00 * USD/EUR 0.8000 | 30.88n | n/a[^noconv] | 348.50n | n/a | +1028.38% |
| Parse | USD 1 | 44.99n | 139.50n | 99.09n | +210.07% | +120.26% |
| Parse | USD 123.456 | 61.45n | 148.60n | 240.90n | +141.82% | +292.03% |
| Parse | USD 123456789.1234567890 | 131.2n | 204.4n | 253.0n | +55.85% | +92.87% |
| String | USD 1 | 38.48n | 200.70n | 89.92n | +421.50% | +133.65% |
| String | USD 123.456 | 56.34n | 229.90n | 127.05n | +308.02% | +125.49% |
| String | USD 123456789.1234567890 | 84.73n | 383.30n | 277.55n | +352.38% | +227.57% |
| Telco | see [specification] | 224.2n | n/a[^nofracmul] | 1944.0n | n/a | +766.89% |

[^nodiv]: [rhymond] does not support division.

[^noconv]: [rhymond] does not support currency conversion.

[^nofracmul]: [rhymond] does not support multiplication by a fraction.

[^nosplit]: [bojanz] does not support splitting into parts.

The benchmark results shown in the table are provided for informational purposes
only and may vary depending on your specific use case.

## Contributing

Interested in contributing? Here's how to get started:
Expand Down Expand Up @@ -111,3 +179,9 @@ This ensures alignment with the project's objectives and roadmap.
[licenseb]: https://img.shields.io/github/license/govalues/money?color=blue
[awesome]: https://github.com/avelino/awesome-go#financial
[awesomeb]: https://awesome.re/mentioned-badge.svg
[rhymond]: https://pkg.go.dev/github.com/Rhymond/go-money
[bojanz]: https://pkg.go.dev/github.com/bojanz/currency
[cockroachdb]: https://pkg.go.dev/github.com/cockroachdb/apd
[shopspring]: https://pkg.go.dev/github.com/shopspring/decimal
[specification]: https://speleotrove.com/decimal/telcoSpec.html
[cross-validate]: https://github.com/govalues/decimal-tests/blob/main/decimal_fuzz_test.go
60 changes: 50 additions & 10 deletions amount.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"fmt"
"math"
"strconv"
"strings"

"github.com/govalues/decimal"
)
Expand Down Expand Up @@ -321,7 +320,7 @@ func (a Amount) Neg() Amount {

// CopySign returns an amount with the same sign as amount b.
// The currency of amount b is ignored.
// CopySign treates zero as positive.
// CopySign treates 0 as positive.
// See also method [Amount.Sign].
func (a Amount) CopySign(b Amount) Amount {
d, e := a.Decimal(), b.Decimal()
Expand Down Expand Up @@ -502,7 +501,7 @@ func (a Amount) mul(e decimal.Decimal) (Amount, error) {
// See also methods [Amount.QuoRem], [Amount.Rat], and [Amount.Split].
//
// Quo returns an error if:
// - the divisor is zero;
// - the divisor is 0;
// - the integer part of the result has more than ([decimal.MaxPrec] - [Currency.Scale]) digits.
// For example, when currency is US Dollars, Quo will return an error if the integer
// part of the result has more than 17 digits (19 - 2 = 17).
Expand All @@ -529,7 +528,7 @@ func (a Amount) quo(e decimal.Decimal) (Amount, error) {
// See also methods [Amount.Quo], [Amount.Rat], and [Amount.Split].
//
// QuoRem returns an error if:
// - the divisor is zero;
// - the divisor is 0;
// - the integer part of the result has more than [decimal.MaxPrec] digits.
func (a Amount) QuoRem(e decimal.Decimal) (q, r Amount, err error) {
q, r, err = a.quoRem(e)
Expand Down Expand Up @@ -567,7 +566,7 @@ func (a Amount) quoRem(e decimal.Decimal) (q, r Amount, err error) {
// See also methods [Amount.Quo], [Amount.QuoRem], and [Amount.Split].
//
// Rat returns an error if:
// - the divisor is zero;
// - the divisor is 0;
// - the integer part of the result has more than [decimal.MaxPrec] digits.
func (a Amount) Rat(b Amount) (decimal.Decimal, error) {
d, e := a.Decimal(), b.Decimal()
Expand Down Expand Up @@ -849,11 +848,52 @@ func (a Amount) SameScaleAsCurr() bool {
// [fmt.Stringer]: https://pkg.go.dev/fmt#Stringer
// [Decimal.String]: https://pkg.go.dev/github.com/govalues/decimal#Decimal.String
func (a Amount) String() string {
var b strings.Builder
b.WriteString(a.Curr().String())
b.WriteByte(' ')
b.WriteString(a.Decimal().String())
return b.String()
var buf [32]byte
pos := len(buf) - 1
coef := a.Decimal().Coef()
scale := a.Decimal().Scale()

// Coefficient
for {
buf[pos] = byte(coef%10) + '0'
pos--
coef /= 10
if scale > 0 {
scale--
// Decimal point
if scale == 0 {
buf[pos] = '.'
pos--
// Leading 0
if coef == 0 {
buf[pos] = '0'
pos--
}
}
}
if coef == 0 && scale == 0 {
break
}
}

// Sign
if a.Decimal().IsNeg() {
buf[pos] = '-'
pos--
}

// Delimiter
buf[pos] = ' '
pos--

// Currency
curr := a.Curr().Code()
for i := len(curr) - 1; i >= 0; i-- {
buf[pos] = curr[i]
pos--
}

return string(buf[pos+1:])
}

// Cmp compares amounts and returns:
Expand Down
19 changes: 13 additions & 6 deletions currency.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,18 @@ func MustParseCurr(curr string) Currency {
}

// Scale returns the number of digits after the decimal point required for
// the minor unit of the currency.
// This represents the [ratio] of the minor unit to the major unit.
// A scale of 0 means that there is no minor unit for the currency, whereas
// scales of 1, 2, and 3 signify ratios of 10:1, 100:1, and 1000:1, respectively.
//
// [ratio]: https://en.wikipedia.org/wiki/ISO_4217#Minor_unit_fractions
// representing the minor unit of a currency.
// The currently supported currencies use scales of 0, 2, or 3:
// - A scale of 0 indicates currencies without minor units.
// For example, the [Japanese Yen] does not have minor units.
// - A scale of 2 indicates currencies that use 2 digits to represent their minor units.
// For example, the [US Dollar] represents its minor unit, 1 cent, as 0.01 dollars.
// - A scale of 3 indicates currencies with 3 digits in their minor units.
// For instance, the minor unit of the [Omani Rial], 1 baisa, is represented as 0.001 rials.
//
// [Japanese Yen]: https://en.wikipedia.org/wiki/Japanese_yen
// [US Dollar]: https://en.wikipedia.org/wiki/United_States_dollar
// [Omani Rial]: https://en.wikipedia.org/wiki/Omani_rial
func (c Currency) Scale() int {
return int(scaleLookup[c])
}
Expand All @@ -81,6 +87,7 @@ func (c Currency) Code() string {

// String method implements the [fmt.Stringer] interface and returns
// a string representation of the Currency value.
// See also method [Currency.Format].
func (c Currency) String() string {
return c.Code()
}
Expand Down
Loading

0 comments on commit b224928

Please sign in to comment.