storj/satellite/payments/monetary/amount.go
paul cannon a16aecfa96 satellite/payments: specialized type for monetary amounts
Why: big.Float is not an ideal type for dealing with monetary amounts,
because no matter how high the precision, some non-integer decimal
values can not be represented exactly in base-2 floating point. Also,
storing gob-encoded big.Float values in the database makes it very hard
to use those values in meaningful queries, making it difficult to do
any sort of analysis on billing.

For better accuracy, then, we can just represent monetary values as
integers (in whatever base units are appropriate for the currency). For
example, STORJ tokens or Bitcoins can not be split into pieces smaller
than 10^-8, so we can store amounts of STORJ or BTC with precision
simply by moving the decimal point 8 digits to the right. For USD values
(assuming we don't want to deal with fractional cents), we can move the
decimal point 2 digits to the right.

To make it easier and less error-prone to deal with the math involved, I
introduce here a new type, monetary.Amount, instances of which have an
associated value _and_ a currency.

Change-Id: I03395d52f0e2473cf301361f6033722b54640265
2021-09-28 23:27:44 +00:00

173 lines
5.8 KiB
Go

// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
package monetary
import (
"fmt"
"math"
"math/big"
"github.com/shopspring/decimal"
"github.com/zeebo/errs"
)
// Currency represents a currency for the purpose of representing amounts in
// that currency. Currency instances have a name, a symbol, and a number of
// supported decimal places of supported precision.
type Currency struct {
name string
symbol string
decimalPlaces int32
}
// NewCurrency creates a new Currency instance.
func NewCurrency(name, symbol string, decimalPlaces int32) *Currency {
return &Currency{name: name, symbol: symbol, decimalPlaces: decimalPlaces}
}
// Name returns the name of the currency.
func (c *Currency) Name() string {
return c.name
}
// Symbol returns the symbol of the currency.
func (c *Currency) Symbol() string {
return c.symbol
}
var (
// StorjToken is the currency for the STORJ ERC20 token, which powers
// most payments on the current Storj network.
StorjToken = NewCurrency("STORJ Token", "STORJ", 8)
// USDollars is the currency of United States dollars, where fractional
// cents are not supported.
USDollars = NewCurrency("US dollars", "USD", 2)
// Bitcoin is the currency for the well-known cryptocurrency Bitcoin
// (a.k.a. BTC).
Bitcoin = NewCurrency("Bitcoin (BTC)", "BTC", 8)
// LiveGoats is the currency of live goats, which some Storj network
// satellites may elect to support for payments.
LiveGoats = NewCurrency("Live goats", "goats", 0)
// Error is a class of errors encountered in the monetary package.
Error = errs.Class("monetary error")
)
// Amount represents a monetary amount, encapsulating a value and a currency.
//
// The value of the Amount is represented in "base units", meaning units of the
// smallest indivisible portion of the given currency. For example, when
// the currency is USDollars, the base unit would be cents.
type Amount struct {
baseUnits int64
currency *Currency
}
// AsFloat returns the monetary value in currency units expressed as a
// floating point number. _Warning_ may lose precision! (float64 has the
// equivalent of 53 bits of precision, as defined by big.Float.)
func (a Amount) AsFloat() float64 {
return float64(a.baseUnits) * math.Pow10(int(-a.currency.decimalPlaces))
}
// AsBigFloat returns the monetary value in currency units expressed as an
// instance of *big.Float with precision=64. _Warning_ may lose precision!
func (a Amount) AsBigFloat() *big.Float {
return a.AsBigFloatWithPrecision(64)
}
// AsBigFloatWithPrecision returns the monetary value in currency units
// expressed as an instance of *big.Float with the given precision.
// _Warning_ this may lose precision if the specified precision is not
// large enough!
func (a Amount) AsBigFloatWithPrecision(p uint) *big.Float {
stringVal := a.AsDecimal().String()
bf, _, err := big.ParseFloat(stringVal, 10, p, big.ToNearestEven)
if err != nil {
// it does not appear that this is possible, after a review of
// decimal.Decimal{}.String() and big.ParseFloat().
panic(fmt.Sprintf("could not parse output of Decimal.String() (%q) as big.Float: %v", stringVal, err))
}
return bf
}
// AsDecimal returns the monetary value in currency units expressed as an
// arbitrary precision decimal number.
func (a Amount) AsDecimal() decimal.Decimal {
d := decimal.NewFromInt(a.baseUnits)
return d.Shift(-a.currency.decimalPlaces)
}
// BaseUnits returns the monetary value expressed in its base units.
func (a Amount) BaseUnits() int64 {
return a.baseUnits
}
// Currency returns the currency of the amount.
func (a Amount) Currency() *Currency {
return a.currency
}
// Equal returns true if a and other are in the same currency and have the
// same value.
func (a Amount) Equal(other Amount) bool {
return a.currency == other.currency && a.baseUnits == other.baseUnits
}
// AmountFromBaseUnits creates a new Amount instance from the given count of
// base units and in the given currency.
func AmountFromBaseUnits(units int64, currency *Currency) Amount {
return Amount{
baseUnits: units,
currency: currency,
}
}
// AmountFromDecimal creates a new Amount instance from the given decimal
// value and in the given currency. The decimal value is expected to be in
// currency units.
//
// Example:
//
// AmountFromDecimal(decimal.NewFromFloat(3.50), USDollars) == Amount{baseUnits: 350, currency: USDollars}
func AmountFromDecimal(d decimal.Decimal, currency *Currency) Amount {
return AmountFromBaseUnits(d.Shift(currency.decimalPlaces).Round(0).IntPart(), currency)
}
// AmountFromString creates a new Amount instance from the given base 10
// value and in the given currency. The string is expected to give the
// value of the amount in currency units.
func AmountFromString(s string, currency *Currency) (Amount, error) {
d, err := decimal.NewFromString(s)
if err != nil {
return Amount{}, Error.Wrap(err)
}
return AmountFromDecimal(d, currency), nil
}
// AmountFromBigFloat creates a new Amount instance from the given floating
// point value and in the given currency. The big.Float is expected to give
// the value of the amount in currency units.
func AmountFromBigFloat(f *big.Float, currency *Currency) (Amount, error) {
dec, err := DecimalFromBigFloat(f)
if err != nil {
return Amount{}, err
}
return AmountFromDecimal(dec, currency), nil
}
// DecimalFromBigFloat creates a new decimal.Decimal instance from the given
// floating point value.
func DecimalFromBigFloat(f *big.Float) (decimal.Decimal, error) {
if f.IsInf() {
return decimal.Decimal{}, Error.New("Cannot represent infinite amount")
}
// This is probably not computationally ideal, but it should be the most
// straightforward way to convert (unless/until the decimal package adds
// a NewFromBigFloat method).
stringVal := f.Text('e', -1)
dec, err := decimal.NewFromString(stringVal)
return dec, Error.Wrap(err)
}