From c086c1da731ba4d3763f4c1282829786f2b31895 Mon Sep 17 00:00:00 2001 From: eapenkin Date: Fri, 26 Jul 2024 21:55:11 +0400 Subject: [PATCH] money: allow scales smaller than currency scale --- .github/workflows/go.yml | 40 +-- .gitignore | 9 +- .golangci.yml | 1 + CHANGELOG.md | 6 + README.md | 75 +++--- amount.go | 130 +++------- amount_test.go | 294 ++++++++++----------- currency.go | 21 +- currency_test.go | 538 ++++++++++++++++++++++++++++++++++++++- doc.go | 2 +- doc_test.go | 257 +++++++++---------- exchange_rate.go | 113 +++----- exchange_rate_test.go | 28 +- go.mod | 4 +- go.sum | 4 +- 15 files changed, 954 insertions(+), 568 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index e88d0b6..2652ad9 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -11,30 +11,30 @@ jobs: go-version: [oldstable, stable] runs-on: ubuntu-latest steps: + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + cache: false - - name: Setup Go - uses: actions/setup-go@v4 - with: - go-version: ${{ matrix.go-version }} + - name: Check out code + uses: actions/checkout@v4 - - name: Check out code - uses: actions/checkout@v3 + - name: Verify code formatting + run: gofmt -s -w . && git diff --exit-code - - name: Verify code formatting - run: gofmt -s -w . && git diff --exit-code + - name: Verify dependency consistency + run: go get -u -t . && go mod tidy && git diff --exit-code - - name: Verify dependency consistency - run: go get -u -t . && go mod tidy && git diff --exit-code + - name: Verify generated code + run: go generate ./... && git diff --exit-code - - name: Verify generated code - run: go generate ./... && git diff --exit-code + - name: Verify potential issues + uses: golangci/golangci-lint-action@v4 - - name: Verify potential issues - uses: golangci/golangci-lint-action@v3 + - name: Run tests with coverage + run: go test -race -shuffle=on -coverprofile="coverage.txt" -covermode=atomic ./... - - name: Run tests with coverage - run: go test -race -shuffle=on -coverprofile="coverage.txt" -covermode=atomic ./... - - - name: Upload test coverage - if: matrix.go-version == 'stable' - uses: codecov/codecov-action@v3 + - name: Upload test coverage + if: matrix.go-version == 'stable' + uses: codecov/codecov-action@v4 diff --git a/.gitignore b/.gitignore index 66fd13c..7b62130 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,8 @@ -# Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib - -# Test binary, built with `go test -c` *.test - -# Output of the go coverage tool, specifically when used with LiteIDE *.out - -# Dependency directories (remove the comment below to include it) -# vendor/ +coverage.txt diff --git a/.golangci.yml b/.golangci.yml index 453957e..d1cc2c5 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -11,6 +11,7 @@ linters: - godot - gosec - misspell + - predeclared - stylecheck - revive - staticcheck diff --git a/CHANGELOG.md b/CHANGELOG.md index 97af35e..103cb71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [0.2.3] - 2024-07-26 + +### Changed + +- Allowed rounding to the scale smaller than the currency scale. + ## [0.2.2] - 2023-12-18 ### Changed diff --git a/README.md b/README.md index b56d635..97fde5b 100644 --- a/README.md +++ b/README.md @@ -52,25 +52,25 @@ import ( func main() { // 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 + a, _ := money.NewAmount("USD", 8, 0) // a = $8.00 + b, _ := money.ParseAmount("USD", "12.5") // b = $12.50 + c, _ := money.NewAmountFromFloat64("USD", 2.567) // c = $2.567 + d, _ := money.NewAmountFromInt64("USD", 7, 896, 3) // d = $7.896 + r, _ := money.NewExchRate("USD", "EUR", 9, 1) // r = $/€ 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.Add(b)) // $8.00 + $12.50 + fmt.Println(a.Sub(b)) // $8.00 - $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 8.00 * USD/EUR 0.9 + fmt.Println(a.Mul(x)) // $8.00 * 2 + fmt.Println(a.FMA(x, b)) // $8.00 * 2 + $12.50 + fmt.Println(r.Conv(a)) // $8.00 * $/€ 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 - fmt.Println(a.Rat(b)) // USD 8.00 / USD 12.50 - fmt.Println(a.Split(3)) // USD 8.00 into 3 parts + fmt.Println(a.Quo(x)) // $8.00 ÷ 2 + fmt.Println(a.QuoRem(x)) // $8.00 ÷ 2, $8.00 mod 2 + fmt.Println(a.Rat(b)) // $8.00 ÷ $12.50 + fmt.Println(a.Split(3)) // $8.00 into 3 parts // Rounding to 2 decimal places fmt.Println(c.RoundToCurr()) // 2.57 @@ -95,7 +95,7 @@ func main() { ## Documentation For detailed documentation and additional examples, visit the package -[documentation](https://pkg.go.dev/github.com/govalues/money#pkg-examples). +[documentation](https://pkg.go.dev/github.com/govalues/money#section-documentation). ## Comparison @@ -123,21 +123,21 @@ 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.8 | 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% | +| Test Case | Expression | govalues | [rhymond] v1.0.10 | [bojanz] v1.2.1 | govalues vs rhymond | govalues vs bojanz | +| ----------- | --------------------- | -------: | ----------------: | --------------: | ------------------: | -----------------: | +| Add | $2.00 + $3.00 | 22.95n | 218.30n | 144.10n | +851.41% | +528.02% | +| Mul | $2.00 * 3 | 21.80n | 133.40n | 239.60n | +511.79% | +998.83% | +| QuoExact | $2.00 ÷ 4 | 80.12n | n/a[^nodiv] | 468.05n | n/a | +484.19% | +| QuoInfinite | $2.00 ÷ 3 | 602.1n | n/a[^nodiv] | 512.4n | n/a | -14.91% | +| Split | $2.00 into 10 parts | 374.9n | 897.0n | n/a[^nosplit] | +139.28% | n/a | +| Conv | $2.00 to € | 30.88n | n/a[^noconv] | 348.50n | n/a | +1028.38% | +| Parse | $1 | 44.99n | 139.50n | 99.09n | +210.07% | +120.26% | +| Parse | $123.456 | 61.45n | 148.60n | 240.90n | +141.82% | +292.03% | +| Parse | $123456789.1234567890 | 131.2n | 204.4n | 253.0n | +55.85% | +92.87% | +| String | $1 | 38.48n | 200.70n | 89.92n | +421.50% | +133.65% | +| String | $123.456 | 56.34n | 229.90n | 127.05n | +308.02% | +125.49% | +| String | $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. @@ -150,21 +150,6 @@ cpu: AMD Ryzen 7 3700C with Radeon Vega Mobile Gfx 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: - -1. Fork and clone the repository. -1. Implement your changes. -1. Write tests to cover your changes. -1. Ensure all tests pass with `go test`. -1. Commit and push to your fork. -1. Open a pull request detailing your changes. - -**Note**: If you're considering significant changes, please open an issue first to -discuss with the maintainers. -This ensures alignment with the project's objectives and roadmap. - [codecov]: https://codecov.io/gh/govalues/money [codecovb]: https://img.shields.io/codecov/c/github/govalues/money/main?color=brightcolor [goreport]: https://goreportcard.com/report/github.com/govalues/money diff --git a/amount.go b/amount.go index fa02c0a..47d791f 100644 --- a/amount.go +++ b/amount.go @@ -9,10 +9,13 @@ import ( "github.com/govalues/decimal" ) -var errCurrencyMismatch = errors.New("currency mismatch") +var ( + errAmountOverflow = errors.New("amount overflow") + errCurrencyMismatch = errors.New("currency mismatch") +) // Amount type represents a monetary amount. -// Its zero value corresponds to "XXX 0", where XXX indicates an unknown currency. +// Its zero value corresponds to "XXX 0", where [XXX] indicates an unknown currency. // Amount is designed to be safe for concurrent use by multiple goroutines. type Amount struct { curr Currency // ISO 4217 currency @@ -28,10 +31,9 @@ func newAmountUnsafe(c Currency, d decimal.Decimal) Amount { // newAmountSafe creates a new amount and checks the scale. func newAmountSafe(c Currency, d decimal.Decimal) (Amount, error) { if d.Scale() < c.Scale() { - var err error - d, err = d.Pad(c.Scale()) - if err != nil { - return Amount{}, fmt.Errorf("padding amount: %w", err) + d = d.Pad(c.Scale()) + if d.Scale() < c.Scale() { + return Amount{}, fmt.Errorf("padding amount: %w", errAmountOverflow) } } return newAmountUnsafe(c, d), nil @@ -224,17 +226,17 @@ func MustParseAmount(curr, amount string) Amount { // [rounding half to even]: https://en.wikipedia.org/wiki/Rounding#Rounding_half_to_even func (a Amount) MinorUnits() (units int64, ok bool) { d := a.RoundToCurr().Decimal() - coef := d.Coef() + u := d.Coef() if d.IsNeg() { - if coef > -math.MinInt64 { + if u > -math.MinInt64 { return 0, false } - return -int64(coef), true + return -int64(u), true } - if coef > math.MaxInt64 { + if u > math.MaxInt64 { return 0, false } - return int64(coef), true + return int64(u), true } // Float64 returns the nearest binary floating-point number rounded @@ -260,16 +262,11 @@ func (a Amount) Float64() (f float64, ok bool) { // This method is useful for converting amounts to [protobuf] format. // See also constructor [NewAmountFromInt64]. // -// Int64 returns false if: -// - given scale is smaller than the scale of the currency; -// - the result cannot be represented as a pair of int64 values. +// Int64 returns false if the result cannot be represented as a pair of int64 values. // // [rounding half to even]: https://en.wikipedia.org/wiki/Rounding#Rounding_half_to_even // [protobuf]: https://github.com/googleapis/googleapis/blob/master/google/type/money.proto func (a Amount) Int64(scale int) (whole, frac int64, ok bool) { - if scale < a.Curr().Scale() { - return 0, 0, false - } return a.Decimal().Int64(scale) } @@ -337,11 +334,7 @@ func (a Amount) Scale() int { // without rounding. // See also method [Amount.Trim]. func (a Amount) MinScale() int { - s := a.Decimal().MinScale() - if s < a.Curr().Scale() { - s = a.Curr().Scale() - } - return s + return a.Decimal().MinScale() } // IsZero returns: @@ -662,17 +655,12 @@ func (a Amount) ULP() Amount { // Ceil returns an amount rounded up to the specified number of digits after // the decimal point using [rounding toward positive infinity]. -// If the given scale is less than the scale of the currency, -// the amount will be rounded up to the scale of the currency instead. // See also methods [Amount.CeilToCurr], [Amount.Floor]. // // [rounding toward positive infinity]: https://en.wikipedia.org/wiki/Rounding#Rounding_up func (a Amount) Ceil(scale int) Amount { c, d := a.Curr(), a.Decimal() - if scale < c.Scale() { - scale = c.Scale() - } - d = d.Ceil(scale) + d = d.Ceil(scale).Pad(c.Scale()) return newAmountUnsafe(c, d) } @@ -687,17 +675,12 @@ func (a Amount) CeilToCurr() Amount { // Floor returns an amount rounded down to the specified number of digits after // the decimal point using [rounding toward negative infinity]. -// If the given scale is less than the scale of the currency, -// the amount will be rounded down to the scale of the currency instead. // See also methods [Amount.FloorToCurr], [Amount.Ceil]. // // [rounding toward negative infinity]: https://en.wikipedia.org/wiki/Rounding#Rounding_down func (a Amount) Floor(scale int) Amount { c, d := a.Curr(), a.Decimal() - if scale < c.Scale() { - scale = c.Scale() - } - d = d.Floor(scale) + d = d.Floor(scale).Pad(c.Scale()) return newAmountUnsafe(c, d) } @@ -712,17 +695,12 @@ func (a Amount) FloorToCurr() Amount { // Trunc returns an amount truncated to the specified number of digits after // the decimal point using [rounding toward zero]. -// If the given scale is less than the scale of the currency, -// the amount will be truncated to the scale of the currency instead. // See also method [Amount.TruncToCurr]. // // [rounding toward zero]: https://en.wikipedia.org/wiki/Rounding#Rounding_toward_zero func (a Amount) Trunc(scale int) Amount { c, d := a.Curr(), a.Decimal() - if scale < c.Scale() { - scale = c.Scale() - } - d = d.Trunc(scale) + d = d.Trunc(scale).Pad(c.Scale()) return newAmountUnsafe(c, d) } @@ -737,17 +715,12 @@ func (a Amount) TruncToCurr() Amount { // Round returns an amount rounded to the specified number of digits after // the decimal point using [rounding half to even] (banker's rounding). -// If the given scale is less than the scale of the currency, -// the amount will be rounded to the scale of the currency instead. // See also methods [Amount.Rescale], [Amount.RoundToCurr]. // // [rounding half to even]: https://en.wikipedia.org/wiki/Rounding#Rounding_half_to_even func (a Amount) Round(scale int) Amount { c, d := a.Curr(), a.Decimal() - if scale < c.Scale() { - scale = c.Scale() - } - d = d.Round(scale) + d = d.Round(scale).Pad(c.Scale()) return newAmountUnsafe(c, d) } @@ -763,43 +736,17 @@ func (a Amount) RoundToCurr() Amount { // Quantize returns an amount rescaled to the same scale as amount b. // The currency and the sign of amount b are ignored. // See also methods [Amount.Scale], [Amount.SameScale], [Amount.Rescale]. -// -// Quantize returns an error if the integer part of the result has more than -// ([decimal.MaxPrec] - b.Scale()) digits. -func (a Amount) Quantize(b Amount) (Amount, error) { - c, err := a.rescale(b.Scale()) - if err != nil { - return Amount{}, fmt.Errorf("quantizing %v to the scale of %v: %w", a, b, err) - } - return c, nil +func (a Amount) Quantize(b Amount) Amount { + return a.Rescale(b.Scale()) } // Rescale returns an amount rounded or zero-padded to the given number of digits // after the decimal point. -// If the specified scale is less than the scale of the currency, -// the amount will be rounded to the scale of the currency instead. // See also method [Amount.Round]. -// -// Rescale returns an error if the integer part of the result more than -// ([decimal.MaxPrec] - scale) digits. -func (a Amount) Rescale(scale int) (Amount, error) { - c, err := a.rescale(scale) - if err != nil { - return Amount{}, fmt.Errorf("rescaling %v: %w", a, err) - } - return c, nil -} - -func (a Amount) rescale(scale int) (Amount, error) { +func (a Amount) Rescale(scale int) Amount { c, d := a.Curr(), a.Decimal() - if scale < c.Scale() { - scale = c.Scale() - } - d, err := d.Rescale(scale) - if err != nil { - return Amount{}, err - } - return newAmountSafe(c, d) + d = d.Rescale(scale).Pad(c.Scale()) + return newAmountUnsafe(c, d) } // Trim returns an amount with trailing zeros removed up to the given scale. @@ -808,9 +755,7 @@ func (a Amount) rescale(scale int) (Amount, error) { // See also method [Amount.TrimToCurr]. func (a Amount) Trim(scale int) Amount { c, d := a.Curr(), a.Decimal() - if scale < c.Scale() { - scale = c.Scale() - } + scale = max(scale, c.Scale()) d = d.Trim(scale) return newAmountUnsafe(c, d) } @@ -1045,7 +990,7 @@ func (a Amount) Format(state fmt.State, verb rune) { c, d := a.Curr(), a.Decimal() // Rescaling - tzeroes := 0 + tzeros := 0 if verb == 'f' || verb == 'F' || verb == 'd' || verb == 'D' { scale := 0 switch p, ok := state.Precision(); { @@ -1056,14 +1001,12 @@ func (a Amount) Format(state fmt.State, verb rune) { case verb == 'f' || verb == 'F': scale = d.Scale() } - if scale < c.Scale() { - scale = c.Scale() - } + scale = max(scale, c.Scale()) switch { case scale < d.Scale(): d = d.Round(scale) case scale > d.Scale(): - tzeroes = scale - d.Scale() + tzeros = scale - d.Scale() } } @@ -1089,7 +1032,7 @@ func (a Amount) Format(state fmt.State, verb rune) { // Decimal point dpoint := 0 - if fracdigs > 0 || tzeroes > 0 { + if fracdigs > 0 || tzeros > 0 { dpoint = 1 } @@ -1120,14 +1063,14 @@ func (a Amount) Format(state fmt.State, verb rune) { } // Calculating padding - width := lquote + currsyms + currdel + rsign + intdigs + dpoint + fracdigs + tzeroes + tquote - lspaces, lzeroes, tspaces := 0, 0, 0 + width := lquote + currsyms + currdel + rsign + intdigs + dpoint + fracdigs + tzeros + tquote + lspaces, lzeros, tspaces := 0, 0, 0 if w, ok := state.Width(); ok && w > width { switch { case state.Flag('-'): tspaces = w - width case state.Flag('0') && verb != 'c' && verb != 'C': - lzeroes = w - width + lzeros = w - width default: lspaces = w - width } @@ -1149,8 +1092,8 @@ func (a Amount) Format(state fmt.State, verb rune) { pos-- } - // Trailing zeroes - for i := 0; i < tzeroes; i++ { + // Trailing zeros + for i := 0; i < tzeros; i++ { buf[pos] = '0' pos-- } @@ -1176,8 +1119,8 @@ func (a Amount) Format(state fmt.State, verb rune) { coef /= 10 } - // Leading zeroes - for i := 0; i < lzeroes; i++ { + // Leading zeros + for i := 0; i < lzeros; i++ { buf[pos] = '0' pos-- } @@ -1219,6 +1162,7 @@ func (a Amount) Format(state fmt.State, verb rune) { } // Writing result + //nolint:errcheck switch verb { case 'q', 'Q', 's', 'S', 'v', 'V', 'f', 'F', 'd', 'D', 'c', 'C': state.Write(buf) diff --git a/amount_test.go b/amount_test.go index 3fd0a7f..49fe1e9 100644 --- a/amount_test.go +++ b/amount_test.go @@ -18,7 +18,7 @@ func TestAmount_ZeroValue(t *testing.T) { } } -func TestAmount_Sizeof(t *testing.T) { +func TestAmount_Size(t *testing.T) { a := Amount{} got := unsafe.Sizeof(a) want := uintptr(24) @@ -27,6 +27,18 @@ func TestAmount_Sizeof(t *testing.T) { } } +func TestAmount_Interfaces(t *testing.T) { + var i any = Amount{} + _, ok := i.(fmt.Stringer) + if !ok { + t.Errorf("%T does not implement fmt.Stringer", i) + } + _, ok = i.(fmt.Formatter) + if !ok { + t.Errorf("%T does not implement fmt.Formatter", i) + } +} + func TestNewAmount(t *testing.T) { t.Run("success", func(t *testing.T) { tests := []struct { @@ -115,7 +127,7 @@ func TestNewAmountFromInt64(t *testing.T) { scale int want string }{ - // Zeroes + // Zeros {"JPY", 0, 0, 0, "0"}, {"JPY", 0, 0, 19, "0"}, {"USD", 0, 0, 0, "0.00"}, @@ -237,7 +249,7 @@ func TestNewAmountFromFloat64(t *testing.T) { f float64 want string }{ - // Zeroes + // Zeros {"JPY", 0, "0"}, {"USD", 0, "0.00"}, {"OMR", 0, "0.000"}, @@ -1046,7 +1058,7 @@ func TestAmount_String(t *testing.T) { tests := []struct { curr, a, want string }{ - // Zeroes + // Zeros {"JPY", "0", "JPY 0"}, {"JPY", "0.0", "JPY 0.0"}, {"USD", "0", "USD 0.00"}, @@ -1590,165 +1602,127 @@ func TestAmount_Clamp(t *testing.T) { } func TestAmount_Rescale(t *testing.T) { - t.Run("success", func(t *testing.T) { - tests := []struct { - curr, a string - scale int - want string - }{ - // Padding - {"JPY", "0", 0, "0"}, - {"JPY", "0", 1, "0.0"}, - {"JPY", "0", 2, "0.00"}, - {"JPY", "0", 3, "0.000"}, - {"USD", "0", 0, "0.00"}, - {"USD", "0", 1, "0.00"}, - {"USD", "0", 2, "0.00"}, - {"USD", "0", 3, "0.000"}, - {"OMR", "0", 0, "0.000"}, - {"OMR", "0", 1, "0.000"}, - {"OMR", "0", 2, "0.000"}, - {"OMR", "0", 3, "0.000"}, - {"USD", "0", 17, "0.00000000000000000"}, - {"USD", "0", 18, "0.000000000000000000"}, - {"USD", "0", 19, "0.0000000000000000000"}, - {"USD", "1", 17, "1.00000000000000000"}, - {"USD", "1", 18, "1.000000000000000000"}, - - // Half-to-even rounding - {"USD", "0.0049", 2, "0.00"}, - {"USD", "0.0051", 2, "0.01"}, - {"USD", "0.0149", 2, "0.01"}, - {"USD", "0.0151", 2, "0.02"}, - {"USD", "-0.0049", 2, "0.00"}, - {"USD", "-0.0051", 2, "-0.01"}, - {"USD", "-0.0149", 2, "-0.01"}, - {"USD", "-0.0151", 2, "-0.02"}, - {"USD", "0.0050", 2, "0.00"}, - {"USD", "0.0150", 2, "0.02"}, - {"USD", "0.0250", 2, "0.02"}, - {"USD", "0.0350", 2, "0.04"}, - {"USD", "-0.0050", 2, "0.00"}, - {"USD", "-0.0150", 2, "-0.02"}, - {"USD", "-0.0250", 2, "-0.02"}, - {"USD", "-0.0350", 2, "-0.04"}, - {"USD", "3.0448", 2, "3.04"}, - {"USD", "3.0450", 2, "3.04"}, - {"USD", "3.0452", 2, "3.05"}, - {"USD", "3.0956", 2, "3.10"}, - } - for _, tt := range tests { - a := MustParseAmount(tt.curr, tt.a) - got, err := a.Rescale(tt.scale) - if err != nil { - t.Errorf("%q.Rescale(%v) failed: %v", a, tt.scale, err) - continue - } - want := MustParseAmount(tt.curr, tt.want) - if got != want { - t.Errorf("%q.Rescale(%v) = %q, want %q", a, tt.scale, got, want) - } - } - }) - - t.Run("error", func(t *testing.T) { - tests := map[string]struct { - curr, a string - scale int - }{ - "overflow 1": {"JPY", "9999999999999999999", 1}, - "overflow 2": {"USD", "99999999999999999.99", 3}, - "overflow 3": {"OMR", "9999999999999999.999", 4}, - "overflow 4": {"USD", "0", 20}, - "overflow 5": {"USD", "1", 19}, - } - for _, tt := range tests { - a := MustParseAmount(tt.curr, tt.a) - _, err := a.Rescale(tt.scale) - if err == nil { - t.Errorf("%q.Rescale(%v) did not fail", a, tt.scale) - } + tests := []struct { + curr, a string + scale int + want string + }{ + // Padding + {"JPY", "0", 0, "0"}, + {"JPY", "0", 1, "0.0"}, + {"JPY", "0", 2, "0.00"}, + {"JPY", "0", 3, "0.000"}, + {"USD", "0", 0, "0.00"}, + {"USD", "0", 1, "0.00"}, + {"USD", "0", 2, "0.00"}, + {"USD", "0", 3, "0.000"}, + {"OMR", "0", 0, "0.000"}, + {"OMR", "0", 1, "0.000"}, + {"OMR", "0", 2, "0.000"}, + {"OMR", "0", 3, "0.000"}, + {"USD", "0", 17, "0.00000000000000000"}, + {"USD", "0", 18, "0.000000000000000000"}, + {"USD", "0", 19, "0.0000000000000000000"}, + {"USD", "0", 20, "0.0000000000000000000"}, + {"USD", "1", 17, "1.00000000000000000"}, + {"USD", "1", 18, "1.000000000000000000"}, + {"USD", "1", 19, "1.000000000000000000"}, + + // Half-to-even rounding + {"USD", "0.0049", 2, "0.00"}, + {"USD", "0.0051", 2, "0.01"}, + {"USD", "0.0149", 2, "0.01"}, + {"USD", "0.0151", 2, "0.02"}, + {"USD", "-0.0049", 2, "0.00"}, + {"USD", "-0.0051", 2, "-0.01"}, + {"USD", "-0.0149", 2, "-0.01"}, + {"USD", "-0.0151", 2, "-0.02"}, + {"USD", "0.0050", 2, "0.00"}, + {"USD", "0.0150", 2, "0.02"}, + {"USD", "0.0250", 2, "0.02"}, + {"USD", "0.0350", 2, "0.04"}, + {"USD", "-0.0050", 2, "0.00"}, + {"USD", "-0.0150", 2, "-0.02"}, + {"USD", "-0.0250", 2, "-0.02"}, + {"USD", "-0.0350", 2, "-0.04"}, + {"USD", "3.0448", 2, "3.04"}, + {"USD", "3.0450", 2, "3.04"}, + {"USD", "3.0452", 2, "3.05"}, + {"USD", "3.0956", 2, "3.10"}, + + // Padding overflow + {"JPY", "9999999999999999999", 1, "9999999999999999999"}, + {"USD", "99999999999999999.99", 3, "99999999999999999.99"}, + {"OMR", "9999999999999999.999", 4, "9999999999999999.999"}, + } + for _, tt := range tests { + a := MustParseAmount(tt.curr, tt.a) + got := a.Rescale(tt.scale) + want := MustParseAmount(tt.curr, tt.want) + if got != want { + t.Errorf("%q.Rescale(%v) = %q, want %q", a, tt.scale, got, want) } - }) + } } func TestAmount_Quantize(t *testing.T) { - t.Run("success", func(t *testing.T) { - tests := []struct { - curr, a, b, want string - }{ - // Padding - {"JPY", "0", "0", "0"}, - {"JPY", "0", "0.0", "0.0"}, - {"JPY", "0", "0.00", "0.00"}, - {"JPY", "0", "0.000", "0.000"}, - {"USD", "0", "0", "0.00"}, - {"USD", "0", "0.0", "0.00"}, - {"USD", "0", "0.00", "0.00"}, - {"USD", "0", "0.000", "0.000"}, - {"OMR", "0", "0", "0.000"}, - {"OMR", "0", "0.0", "0.000"}, - {"OMR", "0", "0.00", "0.000"}, - {"OMR", "0", "0.000", "0.000"}, - {"USD", "0", "0.00000000000000000", "0.00000000000000000"}, - {"USD", "0", "0.000000000000000000", "0.000000000000000000"}, - {"USD", "0", "0.0000000000000000000", "0.0000000000000000000"}, - {"USD", "1", "0.00000000000000000", "1.00000000000000000"}, - {"USD", "1", "0.000000000000000000", "1.000000000000000000"}, - - // Half-to-even rounding - {"USD", "0.0049", "0.00", "0.00"}, - {"USD", "0.0051", "0.00", "0.01"}, - {"USD", "0.0149", "0.00", "0.01"}, - {"USD", "0.0151", "0.00", "0.02"}, - {"USD", "-0.0049", "0.00", "0.00"}, - {"USD", "-0.0051", "0.00", "-0.01"}, - {"USD", "-0.0149", "0.00", "-0.01"}, - {"USD", "-0.0151", "0.00", "-0.02"}, - {"USD", "0.0050", "0.00", "0.00"}, - {"USD", "0.0150", "0.00", "0.02"}, - {"USD", "0.0250", "0.00", "0.02"}, - {"USD", "0.0350", "0.00", "0.04"}, - {"USD", "-0.0050", "0.00", "0.00"}, - {"USD", "-0.0150", "0.00", "-0.02"}, - {"USD", "-0.0250", "0.00", "-0.02"}, - {"USD", "-0.0350", "0.00", "-0.04"}, - {"USD", "3.0448", "0.00", "3.04"}, - {"USD", "3.0450", "0.00", "3.04"}, - {"USD", "3.0452", "0.00", "3.05"}, - {"USD", "3.0956", "0.00", "3.10"}, - } - for _, tt := range tests { - a := MustParseAmount(tt.curr, tt.a) - b := MustParseAmount(tt.curr, tt.b) - got, err := a.Quantize(b) - if err != nil { - t.Errorf("%q.Quantize(%q) failed: %v", a, b, err) - continue - } - want := MustParseAmount(tt.curr, tt.want) - if got != want { - t.Errorf("%q.Quantize(%q) = %q, want %q", a, b, got, want) - } - } - }) - - t.Run("error", func(t *testing.T) { - tests := map[string]struct { - curr, a, b string - }{ - "overflow 1": {"JPY", "9999999999999999999", "0.1"}, - "overflow 2": {"USD", "99999999999999999.99", "0.001"}, - "overflow 3": {"OMR", "9999999999999999.999", "0.0001"}, - "overflow 4": {"USD", "1", "0.0000000000000000000"}, - } - for _, tt := range tests { - a := MustParseAmount(tt.curr, tt.a) - b := MustParseAmount(tt.curr, tt.b) - _, err := a.Quantize(b) - if err == nil { - t.Errorf("%q.Quantize(%q) did not fail", a, b) - } + tests := []struct { + curr, a, b, want string + }{ + // Padding + {"JPY", "0", "0", "0"}, + {"JPY", "0", "0.0", "0.0"}, + {"JPY", "0", "0.00", "0.00"}, + {"JPY", "0", "0.000", "0.000"}, + {"USD", "0", "0", "0.00"}, + {"USD", "0", "0.0", "0.00"}, + {"USD", "0", "0.00", "0.00"}, + {"USD", "0", "0.000", "0.000"}, + {"OMR", "0", "0", "0.000"}, + {"OMR", "0", "0.0", "0.000"}, + {"OMR", "0", "0.00", "0.000"}, + {"OMR", "0", "0.000", "0.000"}, + {"USD", "0", "0.00000000000000000", "0.00000000000000000"}, + {"USD", "0", "0.000000000000000000", "0.000000000000000000"}, + {"USD", "0", "0.0000000000000000000", "0.0000000000000000000"}, + {"USD", "1", "0.00000000000000000", "1.00000000000000000"}, + {"USD", "1", "0.000000000000000000", "1.000000000000000000"}, + + // Half-to-even rounding + {"USD", "0.0049", "0.00", "0.00"}, + {"USD", "0.0051", "0.00", "0.01"}, + {"USD", "0.0149", "0.00", "0.01"}, + {"USD", "0.0151", "0.00", "0.02"}, + {"USD", "-0.0049", "0.00", "0.00"}, + {"USD", "-0.0051", "0.00", "-0.01"}, + {"USD", "-0.0149", "0.00", "-0.01"}, + {"USD", "-0.0151", "0.00", "-0.02"}, + {"USD", "0.0050", "0.00", "0.00"}, + {"USD", "0.0150", "0.00", "0.02"}, + {"USD", "0.0250", "0.00", "0.02"}, + {"USD", "0.0350", "0.00", "0.04"}, + {"USD", "-0.0050", "0.00", "0.00"}, + {"USD", "-0.0150", "0.00", "-0.02"}, + {"USD", "-0.0250", "0.00", "-0.02"}, + {"USD", "-0.0350", "0.00", "-0.04"}, + {"USD", "3.0448", "0.00", "3.04"}, + {"USD", "3.0450", "0.00", "3.04"}, + {"USD", "3.0452", "0.00", "3.05"}, + {"USD", "3.0956", "0.00", "3.10"}, + + // Padding overflow + {"JPY", "9999999999999999999", "0.1", "9999999999999999999"}, + {"USD", "99999999999999999.99", "0.001", "99999999999999999.99"}, + {"OMR", "9999999999999999.999", "0.0001", "9999999999999999.999"}, + {"USD", "1", "0.0000000000000000000", "1.0000000000000000000"}, + } + for _, tt := range tests { + a := MustParseAmount(tt.curr, tt.a) + b := MustParseAmount(tt.curr, tt.b) + got := a.Quantize(b) + want := MustParseAmount(tt.curr, tt.want) + if got != want { + t.Errorf("%q.Quantize(%q) = %q, want %q", a, b, got, want) } - }) + } } diff --git a/currency.go b/currency.go index 4f9f6cc..beddda6 100644 --- a/currency.go +++ b/currency.go @@ -9,16 +9,18 @@ import ( //go:generate go run scripts/currency/codegen.go // Currency type represents a currency in the global financial system. -// The zero value is "XXX", which indicates an unknown currency. +// The zero value is [XXX], which indicates an unknown currency. // // Currency is implemented as an integer index into an in-memory array that -// stores information such as code and scale. +// stores properties defined by [ISO 4217], such as code and scale. // This design ensures safe concurrency for multiple goroutines accessing // the same Currency value. // // When persisting a currency value, use the alphabetic code returned by // the [Currency.Code] method, rather than the integer index, as mapping between // index and a particular currency may change in future versions. +// +// [ISO 4217]: https://en.wikipedia.org/wiki/ISO_4217 type Currency uint8 var errUnknownCurrency = errors.New("unknown currency") @@ -44,7 +46,7 @@ func ParseCurr(curr string) (Currency, error) { func MustParseCurr(curr string) Currency { c, err := ParseCurr(curr) if err != nil { - panic(fmt.Sprintf("MustParseCurr(%q) failed: %v", curr, err)) + panic(fmt.Sprintf("ParseCurr(%q) failed: %v", curr, err)) } return c } @@ -114,13 +116,17 @@ func (c Currency) MarshalText() ([]byte, error) { // See also method [ParseCurr]. // // [sql.Scanner]: https://pkg.go.dev/database/sql#Scanner -func (c *Currency) Scan(v any) error { +func (c *Currency) Scan(value any) error { var err error - switch v := v.(type) { + switch value := value.(type) { case string: - *c, err = ParseCurr(v) + *c, err = ParseCurr(value) + case []byte: + *c, err = ParseCurr(string(value)) + case nil: + err = fmt.Errorf("converting to %T: nil is not supported", c) default: - err = fmt.Errorf("failed to convert from %T to %T", v, XXX) + err = fmt.Errorf("converting from %T to %T: type %T is not supported", value, c, value) } return err } @@ -203,6 +209,7 @@ func (c Currency) Format(state fmt.State, verb rune) { } // Writing result + //nolint:errcheck switch verb { case 'q', 'Q', 's', 'S', 'v', 'V', 'c', 'C': state.Write(buf) diff --git a/currency_test.go b/currency_test.go index cf94bc3..9cf8da7 100644 --- a/currency_test.go +++ b/currency_test.go @@ -1,10 +1,46 @@ package money import ( + "database/sql" + "database/sql/driver" + "encoding" "fmt" "testing" ) +func TestCurrency_Interfaces(t *testing.T) { + var c any + + c = XXX + _, ok := c.(fmt.Stringer) + if !ok { + t.Errorf("%T does not implement fmt.Stringer", c) + } + _, ok = c.(fmt.Formatter) + if !ok { + t.Errorf("%T does not implement fmt.Formatter", c) + } + _, ok = c.(encoding.TextMarshaler) + if !ok { + t.Errorf("%T does not implement encoding.TextMarshaler", c) + } + _, ok = c.(driver.Valuer) + if !ok { + t.Errorf("%T does not implement driver.Valuer", c) + } + + x := XXX + c = &x + _, ok = c.(encoding.TextUnmarshaler) + if !ok { + t.Errorf("%T does not implement encoding.TextUnmarshaler", c) + } + _, ok = c.(sql.Scanner) + if !ok { + t.Errorf("%T does not implement sql.Scanner", c) + } +} + func TestCurrency_Parse(t *testing.T) { t.Run("success", func(t *testing.T) { tests := []struct { @@ -66,12 +102,163 @@ func TestCurrency_Scale(t *testing.T) { want int }{ {XXX, 0}, - {JPY, 0}, + {XTS, 2}, {AED, 2}, + {AFN, 2}, + {ALL, 2}, + {AMD, 2}, + {ANG, 2}, + {AOA, 2}, + {ARS, 2}, + {AUD, 2}, + {AWG, 2}, + {AZN, 2}, + {BAM, 2}, + {BBD, 2}, + {BDT, 2}, + {BGN, 2}, + {BHD, 3}, + {BIF, 0}, + {BMD, 2}, + {BND, 2}, + {BOB, 2}, + {BRL, 2}, + {BSD, 2}, + {BTN, 2}, + {BWP, 2}, + {BYN, 2}, + {BZD, 2}, + {CAD, 2}, + {CDF, 2}, + {CHF, 2}, + {CLP, 0}, + {CNY, 2}, + {COP, 2}, + {CRC, 2}, + {CUP, 2}, + {CVE, 2}, + {CZK, 2}, + {DJF, 0}, + {DKK, 2}, + {DOP, 2}, + {DZD, 2}, + {EGP, 2}, + {ERN, 2}, + {ETB, 2}, {EUR, 2}, - {USD, 2}, - {OMR, 3}, + {FJD, 2}, + {FKP, 2}, + {GBP, 2}, + {GEL, 2}, + {GHS, 2}, + {GIP, 2}, + {GMD, 2}, + {GNF, 0}, + {GTQ, 2}, + {GWP, 2}, + {GYD, 2}, + {HKD, 2}, + {HNL, 2}, + {HRK, 2}, + {HTG, 2}, + {HUF, 2}, + {IDR, 2}, + {ILS, 2}, + {INR, 2}, {IQD, 3}, + {IRR, 2}, + {ISK, 2}, + {JMD, 2}, + {JOD, 3}, + {JPY, 0}, + {KES, 2}, + {KGS, 2}, + {KHR, 2}, + {KMF, 0}, + {KPW, 2}, + {KRW, 0}, + {KWD, 3}, + {KYD, 2}, + {KZT, 2}, + {LAK, 2}, + {LBP, 2}, + {LKR, 2}, + {LRD, 2}, + {LSL, 2}, + {LYD, 3}, + {MAD, 2}, + {MDL, 2}, + {MGA, 2}, + {MKD, 2}, + {MMK, 2}, + {MNT, 2}, + {MOP, 2}, + {MRU, 2}, + {MUR, 2}, + {MVR, 2}, + {MWK, 2}, + {MXN, 2}, + {MYR, 2}, + {MZN, 2}, + {NAD, 2}, + {NGN, 2}, + {NIO, 2}, + {NOK, 2}, + {NPR, 2}, + {NZD, 2}, + {OMR, 3}, + {PAB, 2}, + {PEN, 2}, + {PGK, 2}, + {PHP, 2}, + {PKR, 2}, + {PLN, 2}, + {PYG, 0}, + {QAR, 2}, + {RON, 2}, + {RSD, 2}, + {RUB, 2}, + {RWF, 0}, + {SAR, 2}, + {SBD, 2}, + {SCR, 2}, + {SDG, 2}, + {SEK, 2}, + {SGD, 2}, + {SHP, 2}, + {SLL, 2}, + {SOS, 2}, + {SRD, 2}, + {SSP, 2}, + {STN, 2}, + {SYP, 2}, + {SZL, 2}, + {THB, 2}, + {TJS, 2}, + {TMT, 2}, + {TND, 3}, + {TOP, 2}, + {TRY, 2}, + {TTD, 2}, + {TWD, 2}, + {TZS, 2}, + {UAH, 2}, + {UGX, 0}, + {USD, 2}, + {UYU, 2}, + {UZS, 2}, + {VES, 2}, + {VND, 0}, + {VUV, 0}, + {WST, 2}, + {XAF, 0}, + {XCD, 2}, + {XOF, 0}, + {XPF, 0}, + {YER, 2}, + {ZAR, 2}, + {ZMW, 2}, + {ZWL, 2}, } for _, tt := range tests { got := tt.curr.Scale() @@ -87,9 +274,163 @@ func TestCurrency_Num(t *testing.T) { want string }{ {XXX, "999"}, + {XTS, "963"}, + {AED, "784"}, + {AFN, "971"}, + {ALL, "008"}, + {AMD, "051"}, + {ANG, "532"}, + {AOA, "973"}, + {ARS, "032"}, + {AUD, "036"}, + {AWG, "533"}, + {AZN, "944"}, + {BAM, "977"}, + {BBD, "052"}, + {BDT, "050"}, + {BGN, "975"}, + {BHD, "048"}, + {BIF, "108"}, + {BMD, "060"}, + {BND, "096"}, + {BOB, "068"}, + {BRL, "986"}, + {BSD, "044"}, + {BTN, "064"}, + {BWP, "072"}, + {BYN, "933"}, + {BZD, "084"}, + {CAD, "124"}, + {CDF, "976"}, + {CHF, "756"}, + {CLP, "152"}, + {CNY, "156"}, + {COP, "170"}, + {CRC, "188"}, + {CUP, "192"}, + {CVE, "132"}, + {CZK, "203"}, + {DJF, "262"}, + {DKK, "208"}, + {DOP, "214"}, + {DZD, "012"}, + {EGP, "818"}, + {ERN, "232"}, + {ETB, "230"}, + {EUR, "978"}, + {FJD, "242"}, + {FKP, "238"}, + {GBP, "826"}, + {GEL, "981"}, + {GHS, "936"}, + {GIP, "292"}, + {GMD, "270"}, + {GNF, "324"}, + {GTQ, "320"}, + {GWP, "624"}, + {GYD, "328"}, + {HKD, "344"}, + {HNL, "340"}, + {HRK, "191"}, + {HTG, "332"}, + {HUF, "348"}, + {IDR, "360"}, + {ILS, "376"}, + {INR, "356"}, + {IQD, "368"}, + {IRR, "364"}, + {ISK, "352"}, + {JMD, "388"}, + {JOD, "400"}, {JPY, "392"}, - {USD, "840"}, + {KES, "404"}, + {KGS, "417"}, + {KHR, "116"}, + {KMF, "174"}, + {KPW, "408"}, + {KRW, "410"}, + {KWD, "414"}, + {KYD, "136"}, + {KZT, "398"}, + {LAK, "418"}, + {LBP, "422"}, + {LKR, "144"}, + {LRD, "430"}, + {LSL, "426"}, + {LYD, "434"}, + {MAD, "504"}, + {MDL, "498"}, + {MGA, "969"}, + {MKD, "807"}, + {MMK, "104"}, + {MNT, "496"}, + {MOP, "446"}, + {MRU, "929"}, + {MUR, "480"}, + {MVR, "462"}, + {MWK, "454"}, + {MXN, "484"}, + {MYR, "458"}, + {MZN, "943"}, + {NAD, "516"}, + {NGN, "566"}, + {NIO, "558"}, + {NOK, "578"}, + {NPR, "524"}, + {NZD, "554"}, {OMR, "512"}, + {PAB, "590"}, + {PEN, "604"}, + {PGK, "598"}, + {PHP, "608"}, + {PKR, "586"}, + {PLN, "985"}, + {PYG, "600"}, + {QAR, "634"}, + {RON, "946"}, + {RSD, "941"}, + {RUB, "643"}, + {RWF, "646"}, + {SAR, "682"}, + {SBD, "090"}, + {SCR, "690"}, + {SDG, "938"}, + {SEK, "752"}, + {SGD, "702"}, + {SHP, "654"}, + {SLL, "694"}, + {SOS, "706"}, + {SRD, "968"}, + {SSP, "728"}, + {STN, "930"}, + {SYP, "760"}, + {SZL, "748"}, + {THB, "764"}, + {TJS, "972"}, + {TMT, "934"}, + {TND, "788"}, + {TOP, "776"}, + {TRY, "949"}, + {TTD, "780"}, + {TWD, "901"}, + {TZS, "834"}, + {UAH, "980"}, + {UGX, "800"}, + {USD, "840"}, + {UYU, "858"}, + {UZS, "860"}, + {VES, "928"}, + {VND, "704"}, + {VUV, "548"}, + {WST, "882"}, + {XAF, "950"}, + {XCD, "951"}, + {XOF, "952"}, + {XPF, "953"}, + {YER, "886"}, + {ZAR, "710"}, + {ZMW, "967"}, + {ZWL, "932"}, } for _, tt := range tests { got := tt.curr.Num() @@ -105,9 +446,163 @@ func TestCurrency_Code(t *testing.T) { want string }{ {XXX, "XXX"}, + {XTS, "XTS"}, + {AED, "AED"}, + {AFN, "AFN"}, + {ALL, "ALL"}, + {AMD, "AMD"}, + {ANG, "ANG"}, + {AOA, "AOA"}, + {ARS, "ARS"}, + {AUD, "AUD"}, + {AWG, "AWG"}, + {AZN, "AZN"}, + {BAM, "BAM"}, + {BBD, "BBD"}, + {BDT, "BDT"}, + {BGN, "BGN"}, + {BHD, "BHD"}, + {BIF, "BIF"}, + {BMD, "BMD"}, + {BND, "BND"}, + {BOB, "BOB"}, + {BRL, "BRL"}, + {BSD, "BSD"}, + {BTN, "BTN"}, + {BWP, "BWP"}, + {BYN, "BYN"}, + {BZD, "BZD"}, + {CAD, "CAD"}, + {CDF, "CDF"}, + {CHF, "CHF"}, + {CLP, "CLP"}, + {CNY, "CNY"}, + {COP, "COP"}, + {CRC, "CRC"}, + {CUP, "CUP"}, + {CVE, "CVE"}, + {CZK, "CZK"}, + {DJF, "DJF"}, + {DKK, "DKK"}, + {DOP, "DOP"}, + {DZD, "DZD"}, + {EGP, "EGP"}, + {ERN, "ERN"}, + {ETB, "ETB"}, + {EUR, "EUR"}, + {FJD, "FJD"}, + {FKP, "FKP"}, + {GBP, "GBP"}, + {GEL, "GEL"}, + {GHS, "GHS"}, + {GIP, "GIP"}, + {GMD, "GMD"}, + {GNF, "GNF"}, + {GTQ, "GTQ"}, + {GWP, "GWP"}, + {GYD, "GYD"}, + {HKD, "HKD"}, + {HNL, "HNL"}, + {HRK, "HRK"}, + {HTG, "HTG"}, + {HUF, "HUF"}, + {IDR, "IDR"}, + {ILS, "ILS"}, + {INR, "INR"}, + {IQD, "IQD"}, + {IRR, "IRR"}, + {ISK, "ISK"}, + {JMD, "JMD"}, + {JOD, "JOD"}, {JPY, "JPY"}, - {USD, "USD"}, + {KES, "KES"}, + {KGS, "KGS"}, + {KHR, "KHR"}, + {KMF, "KMF"}, + {KPW, "KPW"}, + {KRW, "KRW"}, + {KWD, "KWD"}, + {KYD, "KYD"}, + {KZT, "KZT"}, + {LAK, "LAK"}, + {LBP, "LBP"}, + {LKR, "LKR"}, + {LRD, "LRD"}, + {LSL, "LSL"}, + {LYD, "LYD"}, + {MAD, "MAD"}, + {MDL, "MDL"}, + {MGA, "MGA"}, + {MKD, "MKD"}, + {MMK, "MMK"}, + {MNT, "MNT"}, + {MOP, "MOP"}, + {MRU, "MRU"}, + {MUR, "MUR"}, + {MVR, "MVR"}, + {MWK, "MWK"}, + {MXN, "MXN"}, + {MYR, "MYR"}, + {MZN, "MZN"}, + {NAD, "NAD"}, + {NGN, "NGN"}, + {NIO, "NIO"}, + {NOK, "NOK"}, + {NPR, "NPR"}, + {NZD, "NZD"}, {OMR, "OMR"}, + {PAB, "PAB"}, + {PEN, "PEN"}, + {PGK, "PGK"}, + {PHP, "PHP"}, + {PKR, "PKR"}, + {PLN, "PLN"}, + {PYG, "PYG"}, + {QAR, "QAR"}, + {RON, "RON"}, + {RSD, "RSD"}, + {RUB, "RUB"}, + {RWF, "RWF"}, + {SAR, "SAR"}, + {SBD, "SBD"}, + {SCR, "SCR"}, + {SDG, "SDG"}, + {SEK, "SEK"}, + {SGD, "SGD"}, + {SHP, "SHP"}, + {SLL, "SLL"}, + {SOS, "SOS"}, + {SRD, "SRD"}, + {SSP, "SSP"}, + {STN, "STN"}, + {SYP, "SYP"}, + {SZL, "SZL"}, + {THB, "THB"}, + {TJS, "TJS"}, + {TMT, "TMT"}, + {TND, "TND"}, + {TOP, "TOP"}, + {TRY, "TRY"}, + {TTD, "TTD"}, + {TWD, "TWD"}, + {TZS, "TZS"}, + {UAH, "UAH"}, + {UGX, "UGX"}, + {USD, "USD"}, + {UYU, "UYU"}, + {UZS, "UZS"}, + {VES, "VES"}, + {VND, "VND"}, + {VUV, "VUV"}, + {WST, "WST"}, + {XAF, "XAF"}, + {XCD, "XCD"}, + {XOF, "XOF"}, + {XPF, "XPF"}, + {YER, "YER"}, + {ZAR, "ZAR"}, + {ZMW, "ZMW"}, + {ZWL, "ZWL"}, } for _, tt := range tests { got := tt.curr.Code() @@ -171,20 +666,37 @@ func TestCurrency_Format(t *testing.T) { func TestCurrency_Scan(t *testing.T) { t.Run("error", func(t *testing.T) { - c := XXX - err := c.Scan([]byte("USD")) - if err == nil { - t.Errorf("c.Scan([]byte(\"USD\")) did not fail") + tests := []any{"UUU", 840, []byte{0x08, 0x40}, nil} + for _, tt := range tests { + var got Currency + err := got.Scan(tt) + if err == nil { + t.Errorf("Scan(%q) did not fail", tt) + } } }) } +func TestNullCurrency_Interfaces(t *testing.T) { + var i any = NullCurrency{} + _, ok := i.(driver.Valuer) + if !ok { + t.Errorf("%T does not implement driver.Valuer", i) + } + + i = &NullCurrency{} + _, ok = i.(sql.Scanner) + if !ok { + t.Errorf("%T does not implement sql.Scanner", i) + } +} + func TestNullCurrency_Scan(t *testing.T) { - t.Run("[]byte", func(t *testing.T) { - tests := []string{"UUU"} + t.Run("error", func(t *testing.T) { + tests := []any{"UUU", 840, []byte{0x08, 0x40}} for _, tt := range tests { - got := NullCurrency{} - err := got.Scan([]byte(tt)) + var got NullCurrency + err := got.Scan(tt) if err == nil { t.Errorf("Scan(%q) did not fail", tt) } diff --git a/doc.go b/doc.go index 12d2f27..9c8adf0 100644 --- a/doc.go +++ b/doc.go @@ -46,7 +46,7 @@ Here are the ranges for scales 0, 2, and 3: | US Dollar | 2 | -99,999,999,999,999,999.99 | 99,999,999,999,999,999.99 | | Omani Rial | 3 | -9,999,999,999,999,999.999 | 9,999,999,999,999,999.999 | -Subnoral numbers are not supported by the underlying [decimal.Decimal] type. +Subnormal numbers are not supported by the underlying [decimal.Decimal] type. Consequently, amounts and exchange rates between -0.00000000000000000005 and 0.00000000000000000005 inclusive are rounded to 0. diff --git a/doc_test.go b/doc_test.go index 74320a8..c330d4e 100644 --- a/doc_test.go +++ b/doc_test.go @@ -2,6 +2,7 @@ package money_test import ( "encoding/json" + "encoding/xml" "fmt" "strconv" "strings" @@ -593,19 +594,19 @@ func ExampleCurrency_Scale() { // 3 } -type Value struct { +type Object struct { Currency money.Currency `json:"currency"` } -func ExampleCurrency_UnmarshalText() { - var v Value +func ExampleCurrency_UnmarshalText_json() { + var v Object _ = json.Unmarshal([]byte(`{"currency":"USD"}`), &v) fmt.Println(v) // Output: {USD} } -func ExampleCurrency_MarshalText() { - v := Value{ +func ExampleCurrency_MarshalText_json() { + v := Object{ Currency: money.USD, } b, _ := json.Marshal(v) @@ -613,6 +614,26 @@ func ExampleCurrency_MarshalText() { // Output: {"currency":"USD"} } +type Entity struct { + Currency money.Currency `xml:"Currency"` +} + +func ExampleCurrency_UnmarshalText_xml() { + var v Entity + _ = xml.Unmarshal([]byte(`USD`), &v) + fmt.Println(v) + // Output: {USD} +} + +func ExampleCurrency_MarshalText_xml() { + v := Entity{ + Currency: money.USD, + } + b, _ := xml.Marshal(v) + fmt.Println(string(b)) + // Output: USD +} + func ExampleCurrency_Scan() { u := money.XXX _ = u.Scan("USD") @@ -891,8 +912,8 @@ func ExampleAmount_Int64() { fmt.Println(a.Int64(3)) fmt.Println(a.Int64(4)) // Output: - // 0 0 false - // 0 0 false + // 6 0 true + // 5 7 true // 5 68 true // 5 678 true // 5 6780 true @@ -1016,9 +1037,9 @@ func ExampleAmount_Rescale_currencies() { fmt.Println(b.Rescale(0)) fmt.Println(c.Rescale(0)) // Output: - // JPY 6 - // USD 5.68 - // OMR 5.678 + // JPY 6 + // USD 6.00 + // OMR 6.000 } func ExampleAmount_Rescale_scales() { @@ -1029,11 +1050,11 @@ func ExampleAmount_Rescale_scales() { fmt.Println(a.Rescale(3)) fmt.Println(a.Rescale(4)) // Output: - // USD 5.68 - // USD 5.68 - // USD 5.68 - // USD 5.679 - // USD 5.6789 + // USD 6.00 + // USD 5.70 + // USD 5.68 + // USD 5.679 + // USD 5.6789 } func ExampleAmount_Round_currencies() { @@ -1045,8 +1066,8 @@ func ExampleAmount_Round_currencies() { fmt.Println(c.Round(0)) // Output: // JPY 6 - // USD 5.68 - // OMR 5.678 + // USD 6.00 + // OMR 6.000 } func ExampleAmount_Round_scales() { @@ -1057,8 +1078,8 @@ func ExampleAmount_Round_scales() { fmt.Println(a.Round(3)) fmt.Println(a.Round(4)) // Output: - // USD 5.68 - // USD 5.68 + // USD 6.00 + // USD 5.70 // USD 5.68 // USD 5.679 // USD 5.6789 @@ -1086,9 +1107,9 @@ func ExampleAmount_Quantize() { fmt.Println(a.Quantize(y)) fmt.Println(a.Quantize(z)) // Output: - // JPY 6 - // JPY 5.7 - // JPY 5.68 + // JPY 6 + // JPY 5.7 + // JPY 5.68 } func ExampleAmount_Ceil_currencies() { @@ -1100,8 +1121,8 @@ func ExampleAmount_Ceil_currencies() { fmt.Println(c.Ceil(0)) // Output: // JPY 6 - // USD 5.68 - // OMR 5.678 + // USD 6.00 + // OMR 6.000 } func ExampleAmount_Ceil_scales() { @@ -1112,8 +1133,8 @@ func ExampleAmount_Ceil_scales() { fmt.Println(a.Ceil(3)) fmt.Println(a.Ceil(4)) // Output: - // USD 5.68 - // USD 5.68 + // USD 6.00 + // USD 5.70 // USD 5.68 // USD 5.679 // USD 5.6789 @@ -1141,8 +1162,8 @@ func ExampleAmount_Floor_currencies() { fmt.Println(c.Floor(0)) // Output: // JPY 5 - // USD 5.67 - // OMR 5.678 + // USD 5.00 + // OMR 5.000 } func ExampleAmount_Floor_scales() { @@ -1153,8 +1174,8 @@ func ExampleAmount_Floor_scales() { fmt.Println(a.Floor(3)) fmt.Println(a.Floor(4)) // Output: - // USD 5.67 - // USD 5.67 + // USD 5.00 + // USD 5.60 // USD 5.67 // USD 5.678 // USD 5.6789 @@ -1182,8 +1203,8 @@ func ExampleAmount_Trunc_currencies() { fmt.Println(c.Trunc(0)) // Output: // JPY 5 - // USD 5.67 - // OMR 5.678 + // USD 5.00 + // OMR 5.000 } func ExampleAmount_Trunc_scales() { @@ -1194,8 +1215,8 @@ func ExampleAmount_Trunc_scales() { fmt.Println(a.Trunc(3)) fmt.Println(a.Trunc(4)) // Output: - // USD 5.67 - // USD 5.67 + // USD 5.00 + // USD 5.60 // USD 5.67 // USD 5.678 // USD 5.6789 @@ -1300,20 +1321,7 @@ func ExampleAmount_Scale() { // 2 } -func ExampleAmount_MinScale_currencies() { - a := money.MustParseAmount("JPY", "5.0000") - b := money.MustParseAmount("USD", "5.0000") - c := money.MustParseAmount("OMR", "5.0000") - fmt.Println(a.MinScale()) - fmt.Println(b.MinScale()) - fmt.Println(c.MinScale()) - // Output: - // 0 - // 2 - // 3 -} - -func ExampleAmount_MinScale_scales() { +func ExampleAmount_MinScale() { a := money.MustParseAmount("USD", "5.6000") b := money.MustParseAmount("USD", "5.6700") c := money.MustParseAmount("USD", "5.6780") @@ -1321,7 +1329,7 @@ func ExampleAmount_MinScale_scales() { fmt.Println(b.MinScale()) fmt.Println(c.MinScale()) // Output: - // 2 + // 1 // 2 // 3 } @@ -1758,20 +1766,7 @@ func ExampleExchangeRate_Scale() { // 5 } -func ExampleExchangeRate_MinScale_currencies() { - r := money.MustParseExchRate("EUR", "JPY", "5.0000") - q := money.MustParseExchRate("EUR", "USD", "5.0000") - p := money.MustParseExchRate("EUR", "OMR", "5.0000") - fmt.Println(r.MinScale()) - fmt.Println(q.MinScale()) - fmt.Println(p.MinScale()) - // Output: - // 0 - // 2 - // 3 -} - -func ExampleExchangeRate_MinScale_scales() { +func ExampleExchangeRate_MinScale() { r := money.MustParseExchRate("EUR", "USD", "5.6000") q := money.MustParseExchRate("EUR", "USD", "5.6700") p := money.MustParseExchRate("EUR", "USD", "5.6780") @@ -1779,7 +1774,7 @@ func ExampleExchangeRate_MinScale_scales() { fmt.Println(q.MinScale()) fmt.Println(p.MinScale()) // Output: - // 2 + // 1 // 2 // 3 } @@ -1862,15 +1857,15 @@ func ExampleExchangeRate_Float64() { } func ExampleExchangeRate_Int64() { - a := money.MustParseExchRate("EUR", "USD", "5.678") - fmt.Println(a.Int64(0)) - fmt.Println(a.Int64(1)) - fmt.Println(a.Int64(2)) - fmt.Println(a.Int64(3)) - fmt.Println(a.Int64(4)) - // Output: - // 0 0 false - // 0 0 false + r := money.MustParseExchRate("EUR", "USD", "5.678") + fmt.Println(r.Int64(0)) + fmt.Println(r.Int64(1)) + fmt.Println(r.Int64(2)) + fmt.Println(r.Int64(3)) + fmt.Println(r.Int64(4)) + // Output: + // 6 0 true + // 5 7 true // 5 68 true // 5 678 true // 5 6780 true @@ -1913,14 +1908,14 @@ func ExampleExchangeRate_CanConv() { } func ExampleExchangeRate_Format_currencies() { - a := money.MustParseExchRate("EUR", "JPY", "5") - b := money.MustParseExchRate("EUR", "USD", "5") - c := money.MustParseExchRate("EUR", "OMR", "5") + r := money.MustParseExchRate("EUR", "JPY", "5") + q := money.MustParseExchRate("EUR", "USD", "5") + p := money.MustParseExchRate("EUR", "OMR", "5") fmt.Println("| v | f | b | c |") fmt.Println("| ------------- | ----- | --- | --- |") - fmt.Printf("| %-13[1]v | %5[1]f | %[1]b | %[1]c |\n", a) - fmt.Printf("| %-13[1]v | %5[1]f | %[1]b | %[1]c |\n", b) - fmt.Printf("| %-13[1]v | %5[1]f | %[1]b | %[1]c |\n", c) + fmt.Printf("| %-13[1]v | %5[1]f | %[1]b | %[1]c |\n", r) + fmt.Printf("| %-13[1]v | %5[1]f | %[1]b | %[1]c |\n", q) + fmt.Printf("| %-13[1]v | %5[1]f | %[1]b | %[1]c |\n", p) // Output: // | v | f | b | c | // | ------------- | ----- | --- | --- | @@ -2005,28 +2000,28 @@ func ExampleExchangeRate_String() { } func ExampleExchangeRate_Floor_currencies() { - a := money.MustParseExchRate("EUR", "JPY", "5.678") - b := money.MustParseExchRate("EUR", "USD", "5.678") - c := money.MustParseExchRate("EUR", "OMR", "5.678") - fmt.Println(a.Floor(0)) - fmt.Println(b.Floor(0)) - fmt.Println(c.Floor(0)) + r := money.MustParseExchRate("EUR", "JPY", "5.678") + q := money.MustParseExchRate("EUR", "USD", "5.678") + p := money.MustParseExchRate("EUR", "OMR", "5.678") + fmt.Println(r.Floor(0)) + fmt.Println(q.Floor(0)) + fmt.Println(p.Floor(0)) // Output: // EUR/JPY 5 - // EUR/USD 5.67 - // EUR/OMR 5.678 + // EUR/USD 5.00 + // EUR/OMR 5.000 } func ExampleExchangeRate_Floor_scales() { - a := money.MustParseExchRate("EUR", "USD", "5.6789") - fmt.Println(a.Floor(0)) - fmt.Println(a.Floor(1)) - fmt.Println(a.Floor(2)) - fmt.Println(a.Floor(3)) - fmt.Println(a.Floor(4)) - // Output: - // EUR/USD 5.67 - // EUR/USD 5.67 + r := money.MustParseExchRate("EUR", "USD", "5.6789") + fmt.Println(r.Floor(0)) + fmt.Println(r.Floor(1)) + fmt.Println(r.Floor(2)) + fmt.Println(r.Floor(3)) + fmt.Println(r.Floor(4)) + // Output: + // EUR/USD 5.00 + // EUR/USD 5.60 // EUR/USD 5.67 // EUR/USD 5.678 // EUR/USD 5.6789 @@ -2041,8 +2036,8 @@ func ExampleExchangeRate_Rescale_currencies() { fmt.Println(p.Rescale(0)) // Output: // EUR/JPY 6 - // EUR/USD 5.68 - // EUR/OMR 5.678 + // EUR/USD 6.00 + // EUR/OMR 6.000 } func ExampleExchangeRate_Rescale_scales() { @@ -2053,8 +2048,8 @@ func ExampleExchangeRate_Rescale_scales() { fmt.Println(r.Rescale(3)) fmt.Println(r.Rescale(4)) // Output: - // EUR/USD 5.68 - // EUR/USD 5.68 + // EUR/USD 6.00 + // EUR/USD 5.70 // EUR/USD 5.68 // EUR/USD 5.679 // EUR/USD 5.6789 @@ -2083,8 +2078,8 @@ func ExampleExchangeRate_Round_currencies() { fmt.Println(p.Round(0)) // Output: // EUR/JPY 6 - // EUR/USD 5.68 - // EUR/OMR 5.678 + // EUR/USD 6.00 + // EUR/OMR 6.000 } func ExampleExchangeRate_Round_scales() { @@ -2095,8 +2090,8 @@ func ExampleExchangeRate_Round_scales() { fmt.Println(r.Round(3)) fmt.Println(r.Round(4)) // Output: - // EUR/USD 5.68 - // EUR/USD 5.68 + // EUR/USD 6.00 + // EUR/USD 5.70 // EUR/USD 5.68 // EUR/USD 5.679 // EUR/USD 5.6789 @@ -2116,12 +2111,12 @@ func ExampleExchangeRate_Trim_currencies() { } func ExampleExchangeRate_Trim_scales() { - a := money.MustParseExchRate("EUR", "USD", "5.0000") - fmt.Println(a.Trim(0)) - fmt.Println(a.Trim(1)) - fmt.Println(a.Trim(2)) - fmt.Println(a.Trim(3)) - fmt.Println(a.Trim(4)) + r := money.MustParseExchRate("EUR", "USD", "5.0000") + fmt.Println(r.Trim(0)) + fmt.Println(r.Trim(1)) + fmt.Println(r.Trim(2)) + fmt.Println(r.Trim(3)) + fmt.Println(r.Trim(4)) // Output: // EUR/USD 5.00 // EUR/USD 5.00 @@ -2131,28 +2126,28 @@ func ExampleExchangeRate_Trim_scales() { } func ExampleExchangeRate_Trunc_currencies() { - a := money.MustParseExchRate("EUR", "JPY", "5.678") - b := money.MustParseExchRate("EUR", "USD", "5.678") - c := money.MustParseExchRate("EUR", "OMR", "5.678") - fmt.Println(a.Trunc(0)) - fmt.Println(b.Trunc(0)) - fmt.Println(c.Trunc(0)) + r := money.MustParseExchRate("EUR", "JPY", "5.678") + q := money.MustParseExchRate("EUR", "USD", "5.678") + p := money.MustParseExchRate("EUR", "OMR", "5.678") + fmt.Println(r.Trunc(0)) + fmt.Println(q.Trunc(0)) + fmt.Println(p.Trunc(0)) // Output: // EUR/JPY 5 - // EUR/USD 5.67 - // EUR/OMR 5.678 + // EUR/USD 5.00 + // EUR/OMR 5.000 } func ExampleExchangeRate_Trunc_scales() { - a := money.MustParseExchRate("EUR", "USD", "5.6789") - fmt.Println(a.Trunc(0)) - fmt.Println(a.Trunc(1)) - fmt.Println(a.Trunc(2)) - fmt.Println(a.Trunc(3)) - fmt.Println(a.Trunc(4)) - // Output: - // EUR/USD 5.67 - // EUR/USD 5.67 + r := money.MustParseExchRate("EUR", "USD", "5.6789") + fmt.Println(r.Trunc(0)) + fmt.Println(r.Trunc(1)) + fmt.Println(r.Trunc(2)) + fmt.Println(r.Trunc(3)) + fmt.Println(r.Trunc(4)) + // Output: + // EUR/USD 5.00 + // EUR/USD 5.60 // EUR/USD 5.67 // EUR/USD 5.678 // EUR/USD 5.6789 @@ -2167,8 +2162,8 @@ func ExampleExchangeRate_Ceil_currencies() { fmt.Println(p.Ceil(0)) // Output: // EUR/JPY 6 - // EUR/USD 5.68 - // EUR/OMR 5.678 + // EUR/USD 6.00 + // EUR/OMR 6.000 } func ExampleExchangeRate_Ceil_scales() { @@ -2179,8 +2174,8 @@ func ExampleExchangeRate_Ceil_scales() { fmt.Println(r.Ceil(3)) fmt.Println(r.Ceil(4)) // Output: - // EUR/USD 5.68 - // EUR/USD 5.68 + // EUR/USD 6.00 + // EUR/USD 5.70 // EUR/USD 5.68 // EUR/USD 5.679 // EUR/USD 5.6789 diff --git a/exchange_rate.go b/exchange_rate.go index c1cfa02..d15523c 100644 --- a/exchange_rate.go +++ b/exchange_rate.go @@ -8,8 +8,10 @@ import ( "github.com/govalues/decimal" ) +var errRateOverflow = fmt.Errorf("rate overflow") + // ExchangeRate represents a unidirectional exchange rate between two currencies. -// The zero value corresponds to an exchange rate of "XXX/XXX 0", where XXX indicates +// The zero value corresponds to an exchange rate of "XXX/XXX 0", where [XXX] indicates // an unknown currency. // This type is designed to be safe for concurrent use by multiple goroutines. type ExchangeRate struct { @@ -36,10 +38,9 @@ func newExchRateSafe(b, q Currency, d decimal.Decimal) (ExchangeRate, error) { return ExchangeRate{}, fmt.Errorf("exchange rate between identical currencies must be equal to 1") } if d.Scale() < q.Scale() { - var err error - d, err = d.Pad(q.Scale()) - if err != nil { - return ExchangeRate{}, fmt.Errorf("padding exchange rate: %w", err) + d = d.Pad(q.Scale()) + if d.Scale() < q.Scale() { + return ExchangeRate{}, fmt.Errorf("padding exchange rate: %w", errRateOverflow) } } return newExchRateUnsafe(b, q, d), nil @@ -260,16 +261,11 @@ func (r ExchangeRate) Float64() (f float64, ok bool) { // This method is useful for converting rates to [protobuf] format. // See also constructor [NewExchRateFromInt64]. // -// Int64 returns false if: -// - given scale is smaller than the scale of the quote currency; -// - the result cannot be represented as a pair of int64 values. +// Int64 returns false if the result cannot be represented as a pair of int64 values. // // [rounding half to even]: https://en.wikipedia.org/wiki/Rounding#Rounding_half_to_even // [protobuf]: https://github.com/googleapis/googleapis/blob/master/google/type/money.proto func (r ExchangeRate) Int64(scale int) (whole, frac int64, ok bool) { - if scale < r.Quote().Scale() { - return 0, 0, false - } return r.Decimal().Int64(scale) } @@ -294,7 +290,7 @@ func (r ExchangeRate) CanConv(b Amount) bool { func (r ExchangeRate) Conv(b Amount) (Amount, error) { c, err := r.conv(b) if err != nil { - return Amount{}, fmt.Errorf("computing [%v * %v]: %w", b, r, err) + return Amount{}, fmt.Errorf("converting [%v] to [%v]: %w", b, r.Quote(), err) } return c, nil } @@ -387,11 +383,7 @@ func (r ExchangeRate) Scale() int { // without rounding. // See also method [ExchangeRate.Trim]. func (r ExchangeRate) MinScale() int { - s := r.Decimal().MinScale() - if s < r.Quote().Scale() { - s = r.Quote().Scale() - } - return s + return r.Decimal().MinScale() } // IsZero returns: @@ -436,24 +428,17 @@ func (r ExchangeRate) WithinOne() bool { // Ceil returns a rate rounded up to the specified number of digits after // the decimal point using [rounding toward positive infinity]. -// If the given scale is less than the scale of the quote currency, -// the rate will be rounded up to the scale of the quote currency instead. // See also method [ExchangeRate.Floor]. // // [rounding toward positive infinity]: https://en.wikipedia.org/wiki/Rounding#Rounding_up func (r ExchangeRate) Ceil(scale int) (ExchangeRate, error) { b, q, d := r.Base(), r.Quote(), r.Decimal() - if scale < q.Scale() { - scale = q.Scale() - } - d = d.Ceil(scale) + d = d.Ceil(scale).Pad(q.Scale()) return newExchRateSafe(b, q, d) } // Floor returns a rate rounded down to the specified number of digits after // the decimal point using [rounding toward negative infinity]. -// If the given scale is less than the scale of the quote currency, -// the rate will be rounded down to the scale of the quote currency instead. // See also method [ExchangeRate.Ceil]. // // Floor returns an error if the result is 0. @@ -461,10 +446,7 @@ func (r ExchangeRate) Ceil(scale int) (ExchangeRate, error) { // [rounding toward negative infinity]: https://en.wikipedia.org/wiki/Rounding#Rounding_down func (r ExchangeRate) Floor(scale int) (ExchangeRate, error) { b, q, d := r.Base(), r.Quote(), r.Decimal() - if scale < q.Scale() { - scale = q.Scale() - } - d = d.Floor(scale) + d = d.Floor(scale).Pad(q.Scale()) p, err := newExchRateSafe(b, q, d) if err != nil { return ExchangeRate{}, fmt.Errorf("flooring %v: %w", r, err) @@ -474,18 +456,13 @@ func (r ExchangeRate) Floor(scale int) (ExchangeRate, error) { // Trunc returns a rate truncated to the specified number of digits after // the decimal point using [rounding toward zero]. -// If the given scale is less than the scale of the quote currency, -// the rate will be truncated to the scale of the quote currency instead. // // Trunc returns an error if the result is 0. // // [rounding toward zero]: https://en.wikipedia.org/wiki/Rounding#Rounding_toward_zero func (r ExchangeRate) Trunc(scale int) (ExchangeRate, error) { b, q, d := r.Base(), r.Quote(), r.Decimal() - if scale < q.Scale() { - scale = q.Scale() - } - d = d.Trunc(scale) + d = d.Trunc(scale).Pad(q.Scale()) p, err := newExchRateSafe(b, q, d) if err != nil { return ExchangeRate{}, fmt.Errorf("truncating %v: %w", r, err) @@ -498,17 +475,13 @@ func (r ExchangeRate) Trunc(scale int) (ExchangeRate, error) { // zeros will be removed up to the scale of the quote currency instead. func (r ExchangeRate) Trim(scale int) ExchangeRate { b, q, d := r.Base(), r.Quote(), r.Decimal() - if scale < q.Scale() { - scale = q.Scale() - } + scale = max(scale, q.Scale()) d = d.Trim(scale) return newExchRateUnsafe(b, q, d) } // Round returns a rate rounded to the specified number of digits after // the decimal point using [rounding half to even] (banker's rounding). -// If the given scale is less than the scale of the quote currency, -// the rate will be rounded to the scale of the quote currency instead. // See also method [ExchangeRate.Rescale]. // // Round returns an error if the result is 0. @@ -516,10 +489,7 @@ func (r ExchangeRate) Trim(scale int) ExchangeRate { // [rounding half to even]: https://en.wikipedia.org/wiki/Rounding#Rounding_half_to_even func (r ExchangeRate) Round(scale int) (ExchangeRate, error) { b, q, d := r.Base(), r.Quote(), r.Decimal() - if scale < q.Scale() { - scale = q.Scale() - } - d = d.Round(scale) + d = d.Round(scale).Pad(q.Scale()) p, err := newExchRateSafe(b, q, d) if err != nil { return ExchangeRate{}, fmt.Errorf("rounding %v: %w", r, err) @@ -531,10 +501,7 @@ func (r ExchangeRate) Round(scale int) (ExchangeRate, error) { // The currency and the sign of rate q are ignored. // See also methods [ExchangeRate.Scale], [ExchangeRate.SameScale], [ExchangeRate.Rescale]. // -// Quantize returns an error if: -// - the result is 0; -// - the integer part of the result has more than -// ([decimal.MaxPrec] - [Currency.Scale]) digits. +// Quantize returns an error if the result is 0. func (r ExchangeRate) Quantize(q ExchangeRate) (ExchangeRate, error) { p, err := r.rescale(q.Scale()) if err != nil { @@ -545,14 +512,9 @@ func (r ExchangeRate) Quantize(q ExchangeRate) (ExchangeRate, error) { // Rescale returns a rate rounded or zero-padded to the given number of digits // after the decimal point. -// If the specified scale is less than the scale of the quote currency, -// the amount will be rounded to the scale of the quote currency instead. // See also method [ExchangeRate.Round]. // -// Rescale returns an error if: -// - the result is 0; -// - the integer part of the result has more than -// ([decimal.MaxPrec] - scale) digits. +// Rescale returns an error if the result is 0. func (r ExchangeRate) Rescale(scale int) (ExchangeRate, error) { q, err := r.rescale(scale) if err != nil { @@ -563,13 +525,7 @@ func (r ExchangeRate) Rescale(scale int) (ExchangeRate, error) { func (r ExchangeRate) rescale(scale int) (ExchangeRate, error) { b, q, d := r.Base(), r.Quote(), r.Decimal() - if scale < q.Scale() { - scale = q.Scale() - } - d, err := d.Rescale(scale) - if err != nil { - return ExchangeRate{}, err - } + d = d.Rescale(scale).Pad(q.Scale()) return newExchRateSafe(b, q, d) } @@ -658,28 +614,26 @@ func (r ExchangeRate) Format(state fmt.State, verb rune) { b, q, d := r.Base(), r.Quote(), r.Decimal() // Rescaling - tzeroes := 0 + var tzeros int if verb == 'f' || verb == 'F' { - scale := 0 + var scale int switch p, ok := state.Precision(); { case ok: scale = p default: scale = d.Scale() } - if scale < q.Scale() { - scale = q.Scale() - } + scale = max(scale, q.Scale()) switch { case scale < d.Scale(): d = d.Round(scale) case scale > d.Scale(): - tzeroes = scale - d.Scale() + tzeros = scale - d.Scale() } } // Integer and fractional digits - intdigs, fracdigs := 0, 0 + var intdigs, fracdigs int switch rprec := d.Prec(); verb { case 'b', 'B', 'c', 'C': // skip @@ -694,14 +648,14 @@ func (r ExchangeRate) Format(state fmt.State, verb rune) { } // Decimal point - dpoint := 0 - if fracdigs > 0 || tzeroes > 0 { + var dpoint int + if fracdigs > 0 || tzeros > 0 { dpoint = 1 } // Currency codes and delimiters basecode, quocode := "", "" - basesyms, quosyms, pairdel, currdel := 0, 0, 0, 0 + var basesyms, quosyms, pairdel, currdel int switch verb { case 'f', 'F': // skip @@ -721,20 +675,20 @@ func (r ExchangeRate) Format(state fmt.State, verb rune) { } // Opening and closing quotes - lquote, tquote := 0, 0 + var lquote, tquote int if verb == 'q' || verb == 'Q' { lquote, tquote = 1, 1 } // Calculating padding - width := lquote + basesyms + pairdel + quosyms + currdel + intdigs + dpoint + fracdigs + tzeroes + tquote - lspaces, lzeroes, tspaces := 0, 0, 0 + width := lquote + basesyms + pairdel + quosyms + currdel + intdigs + dpoint + fracdigs + tzeros + tquote + var lspaces, lzeros, tspaces int if w, ok := state.Width(); ok && w > width { switch { case state.Flag('-'): tspaces = w - width case state.Flag('0') && verb != 'c' && verb != 'C' && verb != 'b' && verb != 'B': - lzeroes = w - width + lzeros = w - width default: lspaces = w - width } @@ -756,8 +710,8 @@ func (r ExchangeRate) Format(state fmt.State, verb rune) { pos-- } - // Trailing zeroes - for i := 0; i < tzeroes; i++ { + // Trailing zeros + for i := 0; i < tzeros; i++ { buf[pos] = '0' pos-- } @@ -783,8 +737,8 @@ func (r ExchangeRate) Format(state fmt.State, verb rune) { coef /= 10 } - // Leading zeroes - for i := 0; i < lzeroes; i++ { + // Leading zeros + for i := 0; i < lzeros; i++ { buf[pos] = '0' pos-- } @@ -826,6 +780,7 @@ func (r ExchangeRate) Format(state fmt.State, verb rune) { } // Writing result + //nolint:errcheck switch verb { case 'q', 'Q', 's', 'S', 'v', 'V', 'f', 'F', 'b', 'B', 'c', 'C': state.Write(buf) diff --git a/exchange_rate_test.go b/exchange_rate_test.go index 1e73048..8bcbb7b 100644 --- a/exchange_rate_test.go +++ b/exchange_rate_test.go @@ -24,7 +24,7 @@ func TestExchangeRate_ZeroValue(t *testing.T) { } } -func TestExchangeRate_Sizeof(t *testing.T) { +func TestExchangeRate_Size(t *testing.T) { r := ExchangeRate{} got := unsafe.Sizeof(r) want := uintptr(24) @@ -33,6 +33,18 @@ func TestExchangeRate_Sizeof(t *testing.T) { } } +func TestExchangeRate_Interfaces(t *testing.T) { + var i any = ExchangeRate{} + _, ok := i.(fmt.Stringer) + if !ok { + t.Errorf("%T does not implement fmt.Stringer", i) + } + _, ok = i.(fmt.Formatter) + if !ok { + t.Errorf("%T does not implement fmt.Formatter", i) + } +} + func TestMustNewExchRate(t *testing.T) { t.Run("error", func(t *testing.T) { defer func() { @@ -154,7 +166,8 @@ func TestNewExchRateFromInt64(t *testing.T) { }{ "quote currency 1": {"EUR", "UUU", 1, 0, 0}, "base currency 1": {"EEE", "USD", 1, 0, 0}, - "different signs 1": {"EUR", "USD", -1, 1, 0}, + "different signs 1": {"EUR", "USD", -1, 1, 1}, + "different signs 2": {"EUR", "USD", 1, -1, 1}, "fraction range 1": {"EUR", "USD", 1, 1, 0}, "scale range 1": {"EUR", "USD", 1, 1, -1}, "scale range 2": {"EUR", "USD", 1, 0, -1}, @@ -592,8 +605,8 @@ func TestExchangeRate_Floor(t *testing.T) { scale int want string }{ - {"USD", "EUR", "0.8000", 0, "0.80"}, - {"USD", "EUR", "0.0800", 1, "0.08"}, + {"USD", "EUR", "0.8000", 1, "0.80"}, + {"USD", "EUR", "0.0800", 2, "0.08"}, } for _, tt := range tests { r := MustParseExchRate(tt.b, tt.q, tt.r) @@ -614,8 +627,10 @@ func TestExchangeRate_Floor(t *testing.T) { base, quote, r string scale int }{ - "zero rate 1": {"USD", "EUR", "0.0080", 2}, - "zero rate 2": {"USD", "EUR", "0.0008", 3}, + "zero rate 0": {"USD", "EUR", "0.8000", 0}, + "zero rate 1": {"USD", "EUR", "0.0800", 1}, + "zero rate 2": {"USD", "EUR", "0.0080", 2}, + "zero rate 3": {"USD", "EUR", "0.0008", 3}, } for _, tt := range tests { r := MustParseExchRate(tt.base, tt.quote, tt.r) @@ -769,7 +784,6 @@ func TestExchangeRate_Rescale(t *testing.T) { }{ "zero rate 1": {"USD", "EUR", "0.0050", 2}, "zero rate 2": {"USD", "EUR", "0.0005", 3}, - "scale 1": {"USD", "EUR", "0.0005", 20}, } for _, tt := range tests { r := MustParseExchRate(tt.base, tt.quote, tt.r) diff --git a/go.mod b/go.mod index 1541ead..ad43cb0 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,5 @@ module github.com/govalues/money -go 1.20 +go 1.21 -require github.com/govalues/decimal v0.1.19 +require github.com/govalues/decimal v0.1.29 diff --git a/go.sum b/go.sum index b047c84..dec893f 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,2 @@ -github.com/govalues/decimal v0.1.19 h1:cqFTYCcEmnJ8h0flsiLTJGFHp4GLNqf71a9tfSAZPOg= -github.com/govalues/decimal v0.1.19/go.mod h1:irMp3+UfATz5dlLhUagswX2ATLhGDmo/Hoq2MP4/9gg= +github.com/govalues/decimal v0.1.29 h1:GKC5g9y9oWxKIy51czdHTShOABwHm/shVuOVPwG415M= +github.com/govalues/decimal v0.1.29/go.mod h1:LUlHHucpCmA4rJfNrDvMgrWibDpYnDNWqJuNU1/gxW8=