diff options
author | Cypher <cypher@server.ky> | 2023-09-26 17:34:32 -0500 |
---|---|---|
committer | Cypher <cypher@server.ky> | 2023-09-27 07:18:51 -0500 |
commit | e13d5683269d2f35db6a2508452df9c100936378 (patch) | |
tree | edb2ca695f393b510a34d724c1f787a6ae66505d | |
parent | c28ae77c153746c39f346b76d972c17c2c397b04 (diff) | |
download | librefund-e13d5683269d2f35db6a2508452df9c100936378.tar.xz |
Support different BTC unit types in money
Accept and display Bitcoin in different unit types (mBTC or sats) via the
Money type.
-rw-r--r-- | asset/template/contribute.html.tmpl | 14 | ||||
-rw-r--r-- | asset/template/project-contributions.html.tmpl | 2 | ||||
-rw-r--r-- | asset/template/project.html.tmpl | 6 | ||||
-rw-r--r-- | config.go | 3 | ||||
-rw-r--r-- | money.go | 210 | ||||
-rw-r--r-- | money_test.go | 4 | ||||
-rw-r--r-- | payment/bitcoin/bitcoin.go | 41 | ||||
-rw-r--r-- | payment/bitcoin/bitcoin_test.go | 10 | ||||
-rw-r--r-- | payment/taler/taler.go | 2 | ||||
-rw-r--r-- | service/service.go | 21 |
10 files changed, 248 insertions, 65 deletions
diff --git a/asset/template/contribute.html.tmpl b/asset/template/contribute.html.tmpl index 71ce8a5..6efc537 100644 --- a/asset/template/contribute.html.tmpl +++ b/asset/template/contribute.html.tmpl @@ -11,12 +11,12 @@ <h2>Contribute to "{{toTitle .Objective.Name}}"</h2> <ul class="in-short"> {{if .Objective.IsCrowdMatched}} - <li>We ask for <strong>{{.Objective.MinimumLimit.PrettyString}} to {{.Objective.FundingLimit.PrettyString}}</strong>. 🎯 + <li>We ask for <strong>{{.Objective.MinimumLimit.Display}} to {{.Objective.FundingLimit.Display}}</strong>. 🎯 </li> {{end}} - <li>We raised <strong>{{.Objective.TotalMatched.PrettyString}}</strong>. 🔮 + <li>We raised <strong>{{.Objective.TotalMatched.Display}}</strong>. 🔮 </li> - <li>For matching new contributions, we have <strong>{{.Objective.TotalRefunded.PrettyString}}</strong>. 👫 + <li>For matching new contributions, we have <strong>{{.Objective.TotalRefunded.Display}}</strong>. 👫 </li> {{if .Objective.IsCrowdMatched}} <li>Previous contributed amounts will currently be refunded <strong>{{.Objective.TotalRefundedPerc}}</strong>. ↩<li> @@ -63,17 +63,17 @@ <div id="contribute-recommended-amounts" class="input-group inline"> {{range $key, $value := .SuggestedContributions}} - <input type="radio" id="amount-{{$key}}" name="Amount" value="{{$value.AmountString}}" {{if eq $key 0}} checked="true" {{end}}> - <label for="amount-{{$key}}">{{$value.PrettyString}}</label> + <input type="radio" id="amount-{{$key}}" name="Amount" value="{{$value.DisplayAmount}}" {{if eq $key 0}} checked="true" {{end}}> + <label for="amount-{{$key}}">{{$value.Display}}</label> {{end}} </div> <div class="input-group inline"> <input type="radio" name="Amount" value="custom"> <div class="input-group-prefix">{{.Currency}}</div> - <input type="number" value="{{.SuggestedContribution.AmountString}}" step="{{.SuggestedContributionStep}}" name="AmountCustom" placeholder="e.g: {{.MaxContribution.AmountString}}"> + <input type="number" value="{{.SuggestedContribution.DisplayAmount}}" step="{{.SuggestedContributionStep}}" name="AmountCustom" placeholder="e.g: {{.MaxContribution.DisplayAmount}}"> </div> - <p class="instruction"><small>Min: {{.MinContribution.PrettyString}}, Max: {{.MaxContribution.PrettyString}}</small></p> + <p class="instruction"><small>Min: {{.MinContribution.Display}}, Max: {{.MaxContribution.Display}}</small></p> {{if eq "bitcoin" .TransferMethod}} <h3>Refund address</h3> diff --git a/asset/template/project-contributions.html.tmpl b/asset/template/project-contributions.html.tmpl index d0cd223..28ce775 100644 --- a/asset/template/project-contributions.html.tmpl +++ b/asset/template/project-contributions.html.tmpl @@ -13,7 +13,7 @@ {{range .Contributions}} <li class="contribution"> <div class="header"> - <p class="title"><span class="amount">{{.Total.PrettyString}}</span> contributed for "{{.Objective}}"</p> + <p class="title"><span class="amount">{{.Total.Display}}</span> contributed for "{{.Objective}}"</p> <div class="date">On {{.CreatedAt.Format "Monday, 02-Jan-06 15:04:05 MST"}}</div> </div> </li> diff --git a/asset/template/project.html.tmpl b/asset/template/project.html.tmpl index e1af1e5..20be4dd 100644 --- a/asset/template/project.html.tmpl +++ b/asset/template/project.html.tmpl @@ -18,7 +18,7 @@ <p class="description">{{.ShortDescription}}</p> <ul class="stats"> {{if .IsCrowdMatched}} - <li class="stat">🎯 Asking {{.MinimumLimit.PrettyString}} to {{.FundingLimit.PrettyString}} + <li class="stat">🎯 Asking {{.MinimumLimit.Display}} to {{.FundingLimit.Display}} <span class="tooltip">? <span class="tooltip-text"> How much is being asked for to start the objective. @@ -26,7 +26,7 @@ </span> </li> {{end}} - <li class="stat">🔮 Raised {{.TotalMatched}} + <li class="stat">🔮 Raised {{.TotalMatched.Display}} <span class="tooltip">? <span class="tooltip-text"> Total non-refunded value raised from all contributions. @@ -91,7 +91,7 @@ {{range .Contributions}} <li class="contribution"> <div class="header"> - <p class="title"><span class="amount">{{.Total.PrettyString}}</span> contributed for "{{.Objective}}"</p> + <p class="title"><span class="amount">{{.Total.Display}}</span> contributed for "{{.Objective}}"</p> <div class="date">On {{.CreatedAt.Format "Monday, 02-Jan-06 15:04:05 MST"}}</div> </div> </li> @@ -73,6 +73,8 @@ type Config struct { // BitcoinConfirmTarget is the the average number of blocks expected // for confirmations. BitcoinConfirmTarget *int `yaml:"bitcoin-confirm-target,omitempty"` + // BitcoinUnit is the unit to display bitcoin (BTC, mBTC, nBTC, etc) + BitcoinDisplayUnit *string `yaml:"bitcoin-display-unit,omitempty"` //BitcoinTransactionConfirmations are the number of blocks expected //confirmed before considering a transaction successful. BitcoinTransactionConfirmations *int `yaml:"bitcoin-transaction-confirmations"` @@ -129,6 +131,7 @@ var DefaultConfig = Config{ // About three days @ 8.5 minutes per block. Given the expected long // objective livespan, cost is prioritized over transaction time. BitcoinConfirmTarget: IntRef(500), + BitcoinDisplayUnit: StringRef(""), BitcoinEnable: BoolRef(false), BitcoinTransactionConfirmations: IntRef(5), BitcoinRPCConnect: StringRef(""), @@ -12,8 +12,13 @@ import ( "github.com/shopspring/decimal" ) -var curShowFraction = make(map[string]int) +// hideFraction defines whether fraction part of the decimal should be shown +// for the currency. +var hideFraction = make(map[string]bool) +// AddCurrency registers a new currency. The method is identical to Rhymod's +// go-money with the exception of the additional 'showFraction', indicating +// whether the trailing fraction amount (cents) should be displayed. func AddCurrency( code string, grapheme string, @@ -21,18 +26,87 @@ func AddCurrency( decimal string, thousand string, fraction int, - showFraction *int, + showFraction bool, ) *money.Currency { - if showFraction != nil { - if *showFraction >= fraction { - panic("showFraction must be >= fraction") - } - curShowFraction[code] = *showFraction + if !showFraction { + hideFraction[code] = true } cur := money.AddCurrency(code, grapheme, template, decimal, thousand, fraction) return cur } +// CurrencyUnit is unit in which a currency can be represented. For example +// 'BTC' and 'sats'. +type CurrencyUnit struct { + // UnitCode is a globally unique code representing the unit. + UnitCode string + Grapheme string + Template string + Fraction int +} + +var currencyUnits = make(map[string]*CurrencyUnit) +var currencyByUnit = make(map[string]*money.Currency) + +// AddCurrencyUnit adds a new unit the currency can be displayed in. +func AddCurrencyUnit( + curCode string, + unit CurrencyUnit, +) error { + cur := money.GetCurrency(curCode) + if cur == nil { + return errors.Errorf("unknown currency code") + } + + currencyByUnit[unit.UnitCode] = cur + currencyUnits[unit.UnitCode] = &unit + return nil +} + +// GetCurrencyUnit returns information on a type of unit of currency and its currency code. +func GetCurrencyUnit( + unitCode string, +) (*CurrencyUnit, *money.Currency) { + unit := currencyUnits[unitCode] + if unit == nil { + return nil, nil + } + + for k, v := range currencyByUnit { + if k == unitCode { + return unit, v + } + } + panic("each unit code should have a currency") +} + +var defaultCurrencyUnit = make(map[string]*CurrencyUnit) + +// DisplayCurrencyInUnit uses the given currency unit when shown to the user. +// +// Using an empty unitCode resets the default unit. +func DisplayCurrencyInUnit( + unitCode string, +) error { + if unitCode == "" { + delete(defaultCurrencyUnit, unitCode) + return nil + } + unit, cur := GetCurrencyUnit(unitCode) + if cur == nil { + return errors.New("currency unit does not exist") + } + defaultCurrencyUnit[cur.Code] = unit + return nil +} + +// Get the unit to use when displaying a currency. +func GetDisplayCurrencyUnit( + curCode string, +) *CurrencyUnit { + return defaultCurrencyUnit[curCode] +} + type Money money.Money func NewMoney(arg int64, code string) *Money { @@ -49,17 +123,13 @@ func (m *Money) AmountString() string { d := decimal.New(m.Amount(), 0) d = d.Mul(decimal.New(1, int32(-cur.Fraction))) - points, ok := curShowFraction[cur.Code] - if !ok { - points = cur.Fraction - } - for ; points < cur.Fraction; points++ { - if d.Mod(decimal.New(1, int32(-points))).IsZero() { - break - } + + decimals := cur.Fraction + if hideFraction[cur.Code] { + decimals = decimalPoints(&d) } - return d.StringFixed(int32(points)) + return d.StringFixed(int32(decimals)) } func (m *Money) Absolute() (*Money, error) { @@ -129,20 +199,52 @@ func (m *Money) String() string { return fmt.Sprintf("%s %s", m.AmountString(), m.Currency().Code) } -func (m *Money) PrettyString() string { +func (m *Money) Display() string { cur := m.Currency() - sa := m.AmountString() - if cur.Thousand != "" { - strs := strings.Split(sa, ".") - for i := len(strs[0]) - 3; i > 0; i -= 3 { - strs[0] = strs[0][:i] + cur.Thousand + strs[0][i:] + + template := cur.Template + grapheme := cur.Grapheme + + if u := defaultCurrencyUnit[cur.Code]; u != nil { + template = u.Template + grapheme = u.Grapheme + } + + str := strings.Replace(template, "1", m.DisplayAmount(), 1) + str = strings.Replace(str, "$", grapheme, 1) + return str +} + +func (m *Money) DisplayAmount() string { + cur := m.Currency() + + deci := cur.Decimal + fraction := cur.Fraction + thousand := cur.Thousand + if u := defaultCurrencyUnit[cur.Code]; u != nil { + fraction = u.Fraction + thousand = cur.Thousand + } + + d := decimal.New(m.Amount(), -int32(fraction)) + decimals := fraction + if hideFraction[cur.Code] { + decimals = decimalPoints(&d) + } + + strs := strings.Split(d.StringFixed(int32(decimals)), ".") + + integer := strs[0] + if thousand != "" { + for i := len(integer) - 3; i > 0; i -= 3 { + integer = integer[:i] + thousand + integer[i:] } - sa = strings.Join(strs, ".") } - sa = strings.Replace(cur.Template, "1", sa, 1) - sa = strings.Replace(sa, "$", cur.Grapheme, 1) - sa = strings.Replace(sa, ".", cur.Decimal, 1) - return sa + if len(strs) == 1 { + return integer + } + + return integer + deci + strs[1] } func (m *Money) MarshalJSON() ([]byte, error) { @@ -205,33 +307,41 @@ func (s Monies) Sum() (*Money, error) { func NewMoneyFromDecimal( amount decimal.Decimal, - curCode string, + unitOrCurCode string, ) (*Money, error) { - return NewMoneyFromString(amount.String(), curCode) + fraction := 1 + curCode := "" + + if unit := currencyUnits[unitOrCurCode]; unit != nil { + fraction = unit.Fraction + curCode = currencyByUnit[unitOrCurCode].Code + } else if cur := money.GetCurrency(unitOrCurCode); cur != nil { + fraction = cur.Fraction + curCode = cur.Code + } else { + return nil, errors.New("unknown currency") + } + + amount = amount.Mul(decimal.New(1, int32(fraction))) + if amount.BigInt().Cmp(big.NewInt(math.MaxInt64)) > 0 { + // Int64 is used to store money amounts internally. This may + // be a problem with some digital currencies. + return nil, errors.New("amount too large to handle internally") + } + + return NewMoney(amount.IntPart(), curCode), nil } func NewMoneyFromString( amount string, - curCode string, + unitOrCurCode string, ) (*Money, error) { v, err := decimal.NewFromString(amount) if err != nil { return nil, err } - curInfo := money.GetCurrency(curCode) - if curInfo == nil { - return nil, errors.New("unknown currency") - } - v = v.Mul(decimal.New(1, int32(curInfo.Fraction))) - - if v.BigInt().Cmp(big.NewInt(math.MaxInt64)) > 0 { - // Int64 is used to store money amounts internally. This may - // be a problem with some digital currencies. - return nil, errors.New("amount too large to handle internally") - } - - return NewMoney(v.IntPart(), string(curCode)), nil + return NewMoneyFromDecimal(v, unitOrCurCode) } func MustDecimal(amount string) *decimal.Decimal { @@ -242,8 +352,8 @@ func MustDecimal(amount string) *decimal.Decimal { return &ret } -func MustMoney(amount, curCode string) *Money { - ret, err := NewMoneyFromString(amount, curCode) +func MustMoney(amount, unitOrCurCode string) *Money { + ret, err := NewMoneyFromString(amount, unitOrCurCode) if err != nil { panic(err) } @@ -264,6 +374,16 @@ func Decimal(arg *Money) decimal.Decimal { return decimal.New(arg.Amount(), -1*int32(arg.Currency().Fraction)) } +func decimalPoints(d *decimal.Decimal) int { + decimals := 0 + for ; decimals < math.MaxInt; decimals++ { + if d.Mod(decimal.New(1, int32(-decimals))).IsZero() { + return decimals + } + } + return math.MaxInt +} + func Negative(arg *Money) *Money { ret := money.New(-arg.Amount(), arg.Currency().Code) return (*Money)(ret) diff --git a/money_test.go b/money_test.go index c269e1e..196841e 100644 --- a/money_test.go +++ b/money_test.go @@ -15,7 +15,7 @@ func TestBTCFormatting(t *testing.T) { ".", ",", 8, - IntRef(0), + false, ) tests := []struct { Money *Money @@ -45,7 +45,7 @@ func TestBTCFormatting(t *testing.T) { } for _, test := range tests { require.Equal(t, test.String, test.Money.String()) - require.Equal(t, test.PrettyString, test.Money.PrettyString()) + require.Equal(t, test.PrettyString, test.Money.Display()) } } diff --git a/payment/bitcoin/bitcoin.go b/payment/bitcoin/bitcoin.go index 095c278..8b6ac89 100644 --- a/payment/bitcoin/bitcoin.go +++ b/payment/bitcoin/bitcoin.go @@ -17,6 +17,14 @@ const Confirmations = 3 const CurrencyCode = "BTC" +const Symbol = "₿" + +// MillibitSymbol is the symbol for 0.001 BTC. +const MillibitSymbol = "m₿" + +// BitSymbol is the symbol for 0.000001 BTC. +const BitSymbol = "μ₿" + // Scheme is the URI scheme. const Scheme = "bitcoin" @@ -29,12 +37,39 @@ const numTransactionsToCheck = 10 func init() { _ = librefund.AddCurrency( CurrencyCode, - "₿", - "$ 1", + Symbol, + "1 $", ".", ",", 8, - librefund.IntRef(0), + false, + ) + _ = librefund.AddCurrencyUnit( + CurrencyCode, + librefund.CurrencyUnit{ + UnitCode: "mBTC", + Grapheme: MillibitSymbol, + Template: "1 $", + Fraction: 5, + }, + ) + _ = librefund.AddCurrencyUnit( + CurrencyCode, + librefund.CurrencyUnit{ + UnitCode: BitSymbol, + Grapheme: BitSymbol, + Template: "1 $", + Fraction: 2, + }, + ) + _ = librefund.AddCurrencyUnit( + CurrencyCode, + librefund.CurrencyUnit{ + UnitCode: "sats", + Grapheme: "sats", + Template: "1 $", + Fraction: 1, + }, ) } diff --git a/payment/bitcoin/bitcoin_test.go b/payment/bitcoin/bitcoin_test.go index 3cb66fd..e5e7ac4 100644 --- a/payment/bitcoin/bitcoin_test.go +++ b/payment/bitcoin/bitcoin_test.go @@ -68,6 +68,16 @@ func TestNewURIFromString(t *testing.T) { } } +func TestDisplayBTC(t *testing.T) { + librefund.DisplayCurrencyInUnit("mBTC") + + m := librefund.MustMoney("0.0001", "BTC") + require.Equal(t, "0.0001 BTC", m.String()) + + assert.Equal(t, "0.1 m₿", m.Display()) + assert.Equal(t, "0.1", m.DisplayAmount(), "amount only") +} + func loadBTCTransactions(t *testing.T, file string) []ListTransactionRecord { f, err := os.Open(file) require.NoError(t, err) diff --git a/payment/taler/taler.go b/payment/taler/taler.go index ff34462..073ef19 100644 --- a/payment/taler/taler.go +++ b/payment/taler/taler.go @@ -16,6 +16,6 @@ func init() { ".", ",", 2, - nil, + true, ) } diff --git a/service/service.go b/service/service.go index a3ede72..8fda255 100644 --- a/service/service.go +++ b/service/service.go @@ -132,6 +132,7 @@ func NewService(cfg librefund.Config) (*Service, error) { return nil, err } + librefund.DisplayCurrencyInUnit(*cfg.BitcoinDisplayUnit) if *cfg.BitcoinEnable { bitcoin, err := bitcoin.NewPaymentGateway(ret.Config, ret.eventLog) if err != nil { @@ -140,6 +141,7 @@ func NewService(cfg librefund.Config) (*Service, error) { bitcoin.SetLogger(ret.log) ret.bitcoinPG = bitcoin } + if *cfg.GNUTalerEnable { ret.log.Println("enabling experimental Taler payment gateway") taler, err := talerAdapter.NewAdapter(cfg) @@ -149,6 +151,7 @@ func NewService(cfg librefund.Config) (*Service, error) { taler.SetLogger(ret.log) ret.taler = taler } + if *cfg.TestPaymentGatewayEnable { if *cfg.BitcoinEnable || *cfg.GNUTalerEnable { return nil, errors.New("test payment gateway requires other gateways to be disabled.") @@ -249,7 +252,8 @@ func (s *Service) getPaymentGateways() []payment.PaymentGateway { // setFundingAmount updates Funding field with the total contributed amount. // // TODO: Make sourcing of Funding field more intuitive -// Do this by migrating this parameter to a function. +// +// Do this by migrating this parameter to a function. // // The funding amount should be sourced elsewhere, rather than be a field on // the 'objective'. It is tied to the transaction store. @@ -592,6 +596,17 @@ func newPaymentID(t time.Time) string { return fmt.Sprintf("%d.%d-%s", t.Year(), t.YearDay(), s) } +// newMoneyFromString returns the amout used for an objective. +// +// The method is a temperary solution until the web frontend and money package +// are refactored. The currency unit should be given as an argument. +func newMoneyFromString(amount string, curCode string) (*librefund.Money, error) { + if unit := librefund.GetDisplayCurrencyUnit(curCode); unit != nil { + return librefund.NewMoneyFromString(amount, unit.UnitCode) + } + return librefund.NewMoneyFromString(amount, curCode) +} + func newStartPaymentRequest( details ContributionReq, curTime time.Time, @@ -601,7 +616,7 @@ func newStartPaymentRequest( ) (*payment.PaymentRequest, error) { var req payment.PaymentRequest var err error - req.Amount, err = librefund.NewMoneyFromString( + req.Amount, err = newMoneyFromString( details.Amount, obj.Currency().Code, ) @@ -651,7 +666,7 @@ func (s *Service) validateContributeReq( mErr["AgreeToTerms"] = errors.New("must be true") } } - amount, err := librefund.NewMoneyFromString(details.Amount, obj.Currency().Code) + amount, err := newMoneyFromString(details.Amount, obj.Currency().Code) if err != nil { mErr["Amount"] = err } |