aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCypher <cypher@server.ky>2023-09-26 17:34:32 -0500
committerCypher <cypher@server.ky>2023-09-27 07:18:51 -0500
commite13d5683269d2f35db6a2508452df9c100936378 (patch)
treeedb2ca695f393b510a34d724c1f787a6ae66505d
parentc28ae77c153746c39f346b76d972c17c2c397b04 (diff)
downloadlibrefund-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.tmpl14
-rw-r--r--asset/template/project-contributions.html.tmpl2
-rw-r--r--asset/template/project.html.tmpl6
-rw-r--r--config.go3
-rw-r--r--money.go210
-rw-r--r--money_test.go4
-rw-r--r--payment/bitcoin/bitcoin.go41
-rw-r--r--payment/bitcoin/bitcoin_test.go10
-rw-r--r--payment/taler/taler.go2
-rw-r--r--service/service.go21
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>
diff --git a/config.go b/config.go
index 117be87..e52a6bf 100644
--- a/config.go
+++ b/config.go
@@ -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(""),
diff --git a/money.go b/money.go
index 4117038..cb9eba5 100644
--- a/money.go
+++ b/money.go
@@ -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
}