From faaed5fca71709f7bb4e0b0e564089c7a4a1b3a8 Mon Sep 17 00:00:00 2001 From: Cyrill Schumacher Date: Fri, 20 Oct 2017 16:00:25 +0200 Subject: [PATCH] Implement Swedish/Cash rounding fixes #63 (#66) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Performance and allocations not that good but can be optimized later. RoundSwedish aka Cash/Penny/öre rounding rounds decimal to a specific interval. The amount payable for a cash transaction is rounded to the nearest multiple of the minimum currency unit available. The following intervals are available: 5, 10, 15, 25, 50 and 100; any other number throws a panic. 5: 5 cent rounding 3.43 => 3.45 10: 10 cent rounding 3.45 => 3.50 (5 gets rounded up) 15: 10 cent rounding 3.45 => 3.40 (5 gets rounded down) 25: 25 cent rounding 3.41 => 3.50 50: 50 cent rounding 3.75 => 4.00 100: 100 cent rounding 3.50 => 4.00 For more details: https://en.wikipedia.org/wiki/Cash_rounding BenchmarkDecimal_RoundSwedish/five-4 1000000 1918 ns/op 1164 B/op 30 allocs/op BenchmarkDecimal_RoundSwedish/fifteen-4 300000 4331 ns/op 2940 B/op 74 allocs/op --- decimal.go | 61 +++++++++++++++++++++++++++++ decimal_test.go | 100 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) diff --git a/decimal.go b/decimal.go index 6078361..a78e390 100644 --- a/decimal.go +++ b/decimal.go @@ -55,10 +55,16 @@ var MarshalJSONWithoutQuotes = false // Zero constant, to make computations faster. var Zero = New(0, 1) +// fiveDec used in Cash Rounding +var fiveDec = New(5, 0) + var zeroInt = big.NewInt(0) var oneInt = big.NewInt(1) +var twoInt = big.NewInt(2) +var fourInt = big.NewInt(4) var fiveInt = big.NewInt(5) var tenInt = big.NewInt(10) +var twentyInt = big.NewInt(20) // Decimal represents a fixed-point decimal. It is immutable. // number = value * 10 ^ exp @@ -557,6 +563,13 @@ func (d Decimal) StringFixedBank(places int32) string { return rounded.string(false) } +// StringFixedCash returns a Swedish/Cash rounded fixed-point string. For +// more details see the documentation at function RoundCash. +func (d Decimal) StringFixedCash(interval uint8) string { + rounded := d.RoundCash(interval) + return rounded.string(false) +} + // Round rounds the decimal to places decimal places. // If places < 0, it will round the integer part to the nearest 10^(-places). // @@ -617,6 +630,54 @@ func (d Decimal) RoundBank(places int32) Decimal { return round } +// RoundCash aka Cash/Penny/öre rounding rounds decimal to a specific +// interval. The amount payable for a cash transaction is rounded to the nearest +// multiple of the minimum currency unit available. The following intervals are +// available: 5, 10, 15, 25, 50 and 100; any other number throws a panic. +// 5: 5 cent rounding 3.43 => 3.45 +// 10: 10 cent rounding 3.45 => 3.50 (5 gets rounded up) +// 15: 10 cent rounding 3.45 => 3.40 (5 gets rounded down) +// 25: 25 cent rounding 3.41 => 3.50 +// 50: 50 cent rounding 3.75 => 4.00 +// 100: 100 cent rounding 3.50 => 4.00 +// For more details: https://en.wikipedia.org/wiki/Cash_rounding +func (d Decimal) RoundCash(interval uint8) Decimal { + var iVal *big.Int + switch interval { + case 5: + iVal = twentyInt + case 10: + iVal = tenInt + case 15: + if d.exp < 0 { + // TODO: optimize and reduce allocations + orgExp := d.exp + dOne := New(10^-int64(orgExp), orgExp) + d2 := d + d2.exp = 0 + if d2.Mod(fiveDec).Equal(Zero) { + d2.exp = orgExp + d2 = d2.Sub(dOne) + d = d2 + } + } + iVal = tenInt + case 25: + iVal = fourInt + case 50: + iVal = twoInt + case 100: + iVal = oneInt + default: + panic(fmt.Sprintf("Decimal does not support this Cash rounding interval `%d`. Supported: 5, 10, 15, 25, 50, 100", interval)) + } + dVal := Decimal{ + value: iVal, + } + // TODO: optimize those calculations to reduce the high allocations (~29 allocs). + return d.Mul(dVal).Round(0).Div(dVal).Truncate(2) +} + // Floor returns the nearest integer value less than or equal to d. func (d Decimal) Floor() Decimal { d.ensureInitialized() diff --git a/decimal_test.go b/decimal_test.go index 9786bcc..8476bed 100644 --- a/decimal_test.go +++ b/decimal_test.go @@ -1231,6 +1231,106 @@ func TestDecimal_DivRound2(t *testing.T) { } } +func TestDecimal_RoundCash(t *testing.T) { + tests := []struct { + d string + interval uint8 + result string + }{ + {"3.44", 5, "3.45"}, + {"3.43", 5, "3.45"}, + {"3.42", 5, "3.40"}, + {"3.425", 5, "3.45"}, + {"3.47", 5, "3.45"}, + {"3.478", 5, "3.50"}, + {"3.48", 5, "3.50"}, + {"348", 5, "348"}, + + {"3.23", 10, "3.20"}, + {"3.33", 10, "3.30"}, + {"3.53", 10, "3.50"}, + {"3.949", 10, "3.90"}, + {"3.95", 10, "4.00"}, + {"395", 10, "395"}, + + {"6.42", 15, "6.40"}, + {"6.39", 15, "6.40"}, + {"6.35", 15, "6.30"}, + {"6.36", 15, "6.40"}, + {"6.349", 15, "6.30"}, + {"6.30", 15, "6.30"}, + {"666", 15, "666"}, + + {"3.23", 25, "3.25"}, + {"3.33", 25, "3.25"}, + {"3.53", 25, "3.50"}, + {"3.93", 25, "4.00"}, + {"3.41", 25, "3.50"}, + + {"3.249", 50, "3.00"}, + {"3.33", 50, "3.50"}, + {"3.749999999", 50, "3.50"}, + {"3.75", 50, "4.00"}, + {"3.93", 50, "4.00"}, + {"393", 50, "393"}, + + {"3.249", 100, "3.00"}, + {"3.49999", 100, "3.00"}, + {"3.50", 100, "4.00"}, + {"3.75", 100, "4.00"}, + {"3.93", 100, "4.00"}, + {"393", 100, "393"}, + } + for i, test := range tests { + d, _ := NewFromString(test.d) + haveRounded := d.RoundCash(test.interval) + result, _ := NewFromString(test.result) + + if !haveRounded.Equal(result) { + t.Errorf("Index %d: Cash rounding for %q interval %d want %q, have %q", i, test.d, test.interval, test.result, haveRounded) + } + } +} + +func TestDecimal_RoundCash_Panic(t *testing.T) { + defer func() { + if r := recover(); r != nil { + if have, ok := r.(string); ok { + const want = "Decimal does not support this Cash rounding interval `231`. Supported: 5, 10, 15, 25, 50, 100" + if want != have { + t.Errorf("\nWant: %q\nHave: %q", want, have) + } + } else { + t.Errorf("Panic should contain an error string but got:\n%+v", r) + } + } else { + t.Error("Expecting a panic but got nothing") + } + }() + d, _ := NewFromString("1") + d.RoundCash(231) +} + +func BenchmarkDecimal_RoundCash_Five(b *testing.B) { + const want = "3.50" + for i := 0; i < b.N; i++ { + val := New(3478, -3) + if have := val.StringFixedCash(5); have != want { + b.Fatalf("\nHave: %q\nWant: %q", have, want) + } + } +} + +func BenchmarkDecimal_RoundCash_Fifteen(b *testing.B) { + const want = "6.30" + for i := 0; i < b.N; i++ { + val := New(635, -2) + if have := val.StringFixedCash(15); have != want { + b.Fatalf("\nHave: %q\nWant: %q", have, want) + } + } +} + func TestDecimal_Mod(t *testing.T) { type Inp struct { a string