Skip to content

Commit

Permalink
add support for 31 other currencies
Browse files Browse the repository at this point in the history
  • Loading branch information
Changaco committed Oct 22, 2018
1 parent 3781fc0 commit f46179c
Show file tree
Hide file tree
Showing 8 changed files with 129 additions and 64 deletions.
101 changes: 78 additions & 23 deletions liberapay/constants.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# coding: utf8
from __future__ import print_function, unicode_literals

from collections import namedtuple, OrderedDict
from collections import defaultdict, namedtuple, OrderedDict
from datetime import date, datetime, timedelta
from decimal import Decimal, ROUND_UP
from decimal import Decimal, ROUND_FLOOR, ROUND_HALF_UP, ROUND_UP
import re

from jinja2 import StrictUndefined
Expand Down Expand Up @@ -47,6 +47,34 @@ def with_vat(self):
return r[0] if not r[1] else r[1].round_up() if not r[0] else r


def to_precision(x, precision, rounding=ROUND_HALF_UP):
if not x:
return x
# round
factor = Decimal(10) ** (x.log10().to_integral(ROUND_FLOOR) + 1)
r = (x / factor).quantize(Decimal(10) ** -precision, rounding=rounding) * factor
# remove trailing zeros
r = r.quantize(Decimal(10) ** (int(x.log10()) - precision + 1))
return r


def convert_symbolic_amount(amount, target_currency, precision=2, rounding=ROUND_HALF_UP):
from liberapay.website import website
rate = website.currency_exchange_rates[('EUR', target_currency)]
return to_precision(amount * rate, precision, rounding)


class MoneyAutoConvertDict(defaultdict):

def __init__(self, *args, **kw):
super(MoneyAutoConvertDict, self).__init__(None, *args, **kw)

def __missing__(self, currency):
r = Money(convert_symbolic_amount(self['EUR'].amount, currency, 1), currency)
self[currency] = r
return r


StandardTip = namedtuple('StandardTip', 'label weekly monthly yearly')


Expand All @@ -64,13 +92,29 @@ def with_vat(self):

BIRTHDAY = date(2015, 5, 22)

CURRENCIES = ordered_set(['EUR', 'USD'])
CURRENCIES = ordered_set([
'EUR', 'USD',
'AUD', 'BGN', 'BRL', 'CAD', 'CHF', 'CNY', 'CZK', 'DKK', 'GBP', 'HKD', 'HRK',
'HUF', 'IDR', 'ILS', 'INR', 'ISK', 'JPY', 'KRW', 'MXN', 'MYR', 'NOK', 'NZD',
'PHP', 'PLN', 'RON', 'RUB', 'SEK', 'SGD', 'THB', 'TRY', 'ZAR'
])

D_CENT = Decimal('0.01')
D_INF = Decimal('inf')
D_MAX = Decimal('999999999999.99')
D_ZERO = Decimal('0.00')

class _DonationLimits(defaultdict):
def __missing__(self, currency):
r = {
period: (
Money(convert_symbolic_amount(eur_amounts[0], currency, rounding=ROUND_UP), currency),
Money(convert_symbolic_amount(eur_amounts[1], currency, rounding=ROUND_UP), currency)
) for period, eur_amounts in DONATION_LIMITS_EUR_USD.items()
}
self[currency] = r
return r

DONATION_LIMITS_WEEKLY_EUR_USD = (Decimal('0.01'), Decimal('100.00'))
DONATION_LIMITS_EUR_USD = {
'weekly': DONATION_LIMITS_WEEKLY_EUR_USD,
Expand All @@ -79,10 +123,10 @@ def with_vat(self):
'yearly': tuple((x * Decimal(52)).quantize(D_CENT)
for x in DONATION_LIMITS_WEEKLY_EUR_USD),
}
DONATION_LIMITS = {
DONATION_LIMITS = _DonationLimits(None, {
'EUR': {k: (Money(v[0], 'EUR'), Money(v[1], 'EUR')) for k, v in DONATION_LIMITS_EUR_USD.items()},
'USD': {k: (Money(v[0], 'USD'), Money(v[1], 'USD')) for k, v in DONATION_LIMITS_EUR_USD.items()},
}
})

DOMAIN_RE = re.compile(r'''
^
Expand Down Expand Up @@ -232,39 +276,39 @@ def with_vat(self):
}
PAYIN_DIRECT_DEBIT_MAX = {k: Money('2500.00', k) for k in ('EUR', 'USD')}

PAYIN_PAYPAL_MIN_ACCEPTABLE = { # fee > 10%
PAYIN_PAYPAL_MIN_ACCEPTABLE = MoneyAutoConvertDict({ # fee > 10%
'EUR': Money('2.00', 'EUR'),
'USD': Money('2.00', 'USD'),
}
PAYIN_PAYPAL_MIN_RECOMMENDED = { # fee < 8%
})
PAYIN_PAYPAL_MIN_RECOMMENDED = MoneyAutoConvertDict({ # fee < 8%
'EUR': Money('10.00', 'EUR'),
'USD': Money('12.00', 'USD'),
}
PAYIN_PAYPAL_LOW_FEE = { # fee < 6%
})
PAYIN_PAYPAL_LOW_FEE = MoneyAutoConvertDict({ # fee < 6%
'EUR': Money('40.00', 'EUR'),
'USD': Money('48.00', 'USD'),
}
PAYIN_PAYPAL_MAX_ACCEPTABLE = {
})
PAYIN_PAYPAL_MAX_ACCEPTABLE = MoneyAutoConvertDict({
'EUR': Money('5000.00', 'EUR'),
'USD': Money('5000.00', 'USD'),
}
})

PAYIN_STRIPE_MIN_ACCEPTABLE = { # fee > 10%
PAYIN_STRIPE_MIN_ACCEPTABLE = MoneyAutoConvertDict({ # fee > 10%
'EUR': Money('2.00', 'EUR'),
'USD': Money('2.00', 'USD'),
}
PAYIN_STRIPE_MIN_RECOMMENDED = { # fee < 8%
})
PAYIN_STRIPE_MIN_RECOMMENDED = MoneyAutoConvertDict({ # fee < 8%
'EUR': Money('10.00', 'EUR'),
'USD': Money('12.00', 'USD'),
}
PAYIN_STRIPE_LOW_FEE = { # fee < 6%
})
PAYIN_STRIPE_LOW_FEE = MoneyAutoConvertDict({ # fee < 6%
'EUR': Money('40.00', 'EUR'),
'USD': Money('48.00', 'USD'),
}
PAYIN_STRIPE_MAX_ACCEPTABLE = {
})
PAYIN_STRIPE_MAX_ACCEPTABLE = MoneyAutoConvertDict({
'EUR': Money('5000.00', 'EUR'),
'USD': Money('5000.00', 'USD'),
}
})

PAYMENT_METHODS = {
'mango-ba': _("Direct Debit"),
Expand Down Expand Up @@ -366,17 +410,28 @@ def make_standard_tip(label, weekly, currency):
)


class _StandardTips(defaultdict):
def __missing__(self, currency):
r = [
make_standard_tip(
label, convert_symbolic_amount(weekly, currency, rounding=ROUND_UP), currency
) for label, weekly in STANDARD_TIPS_EUR_USD
]
self[currency] = r
return r


STANDARD_TIPS_EUR_USD = (
(_("Symbolic"), Decimal('0.01')),
(_("Small"), Decimal('0.25')),
(_("Medium"), Decimal('1.00')),
(_("Large"), Decimal('5.00')),
(_("Maximum"), DONATION_LIMITS_EUR_USD['weekly'][1]),
)
STANDARD_TIPS = {
STANDARD_TIPS = _StandardTips(None, {
'EUR': [make_standard_tip(label, weekly, 'EUR') for label, weekly in STANDARD_TIPS_EUR_USD],
'USD': [make_standard_tip(label, weekly, 'USD') for label, weekly in STANDARD_TIPS_EUR_USD],
}
})

SUMMARY_MAX_SIZE = 100

Expand Down
2 changes: 1 addition & 1 deletion liberapay/utils/currencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ def __sub__(self, other):
def __repr__(self):
return '%s[%s]' % (
self.__class__.__name__,
', '.join('%s %s' % (a, c) for c, a in self.amounts.items())
', '.join('%s %s' % (a, c) for c, a in self.amounts.items() if a)
)

def __bool__(self):
Expand Down
18 changes: 7 additions & 11 deletions templates/currencies.html
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
% macro currency_buttons(name, selected)
<div class="btn-group btn-group-radio">
% for c in constants.CURRENCIES
<label class="btn btn-default">
<input type="radio" name="{{ name }}" value="{{ c }}"
{{ 'checked' if c == selected else '' }} />
<div class="btn-text">
{{ locale.title(locale.currencies.get(c, c)) }}
({{ locale.currency_symbols.get(c, c) }})
</div>
</label>
<select class="form-control" name="{{ name }}">
% for c in list(constants.CURRENCIES)
<option value="{{ c }}" {{ 'checked' if c == selected else '' }} />
{{ locale.title(locale.currencies.get(c, c)) }}
({{ locale.currency_symbols.get(c, c) }})
</option>
% endfor
</div>
</select>
% endmacro
56 changes: 31 additions & 25 deletions templates/your-tip.html
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
% macro tip_form(tippee, tip=None, inline=False, disabled='')
% set pledging = tippee.__class__.__name__ == 'AccountElsewhere'
% set tippee_name = tippee.friendly_name if pledging else tippee.username
% set tip = tip or user.get_tip_to(tippee.participant or tippee, currency)
% set tip_currency = tip.amount.currency
% set new_currency, accepted_currencies = user.get_currencies_for(tippee, tip)
% if request.qs.get('currency') in accepted_currencies
% set new_currency = request.qs['currency']
% endif
% set currency_mismatch = tip.amount > 0 and tip_currency not in accepted_currencies
% if inline
<form action="/~{{ assert(tip.tippee) }}/tip" method="POST" class="your-tip">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="back_to" value="{{ request.line.uri }}" />
% if currency_mismatch
% set tippee_name = tippee.friendly_name if pledging else tippee.username
<p class="text-warning small">{{ _(
"You are currently donating {money_amount} per week to {name}, "
"but they no longer accept donations in {currency}. You can "
Expand All @@ -32,7 +35,6 @@
</form>
% else
% if tip.amount > 0
% set tippee_name = tippee.friendly_name if pledging else tippee.username
% if currency_mismatch
<p class="alert alert-warning">{{ _(
"You are currently donating {money_amount} per week to {name}, but "
Expand All @@ -53,30 +55,34 @@
% else
<p>{{ _("Please select or input an amount:") }}</p>
% endif
<div class="radio-tabs">
% for c in constants.CURRENCIES
% set is_accepted = c in accepted_currencies
<input type="radio" name="currency" value="{{ c }}"
class="tab-controller" id="currency-{{ c }}"
{{ '' if is_accepted else 'disabled' }}
{{ 'checked' if c == new_currency }} />
<label class="btn btn-default {{ '' if is_accepted else 'disabled' }}"
for="currency-{{ c }}">
{{ locale.title(locale.currencies.get(c, c)) }}
({{ locale.currency_symbols.get(c, c) }})
</label>
% if is_accepted
<form action="/~{{ assert(tip.tippee) }}/tip" method="POST" class="tab-content your-tip">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="currency" value="{{ c }}" />

<div class="form-group">
{{ tip_select(tip, c, tippee, disabled, pledging=pledging) }}
</div>
<form action="/~{{ assert(tip.tippee) }}/tip" method="POST" class="your-tip">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="currency" value="{{ new_currency }}" />
<div class="form-group">
{{ tip_select(tip, new_currency, tippee, disabled, pledging=pledging) }}
</div>
</form>
% if len(accepted_currencies) > 1
<br>
<p>{{ ngettext(
"Wrong currency? {username} also accepts {n} other:",
"Wrong currency? {username} also accepts {n} others:",
n=len(accepted_currencies) - 1, username=tippee_name
) }}</p>
<form action="" method="GET" class="form-inline">
<select class="form-control" name="currency">
% for c in accepted_currencies
% if c != new_currency
<option value="{{ c }}">
{{ locale.title(locale.currencies.get(c, c)) }}
({{ locale.currency_symbols.get(c, c) }})
</option>
% endif
% endfor
</select>
<button class="btn btn-default">{{ _("Switch") }}</button>
</form>
% endif
% endfor
</div>
% endif
% endif
% endmacro

Expand Down
4 changes: 2 additions & 2 deletions tests/py/test_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from liberapay.billing.payday import Payday
from liberapay.models.participant import Participant
from liberapay.testing import EUR, USD, Harness
from liberapay.testing import EUR, Harness
from liberapay.testing.mangopay import FakeTransfersHarness
from liberapay.utils.history import (
get_end_of_period_balances, get_start_of_current_utc_day, get_wallet_ledger
Expand Down Expand Up @@ -136,7 +136,7 @@ def test_get_end_of_period_balances(self):
today = get_start_of_current_utc_day()
period_end = today.replace(month=1, day=1)
balances = get_end_of_period_balances(self.db, self.alice, period_end, today)
assert list(balances) == [EUR('10.00'), USD('0.00')]
assert balances == EUR('10.00')


class TestExport(Harness):
Expand Down
8 changes: 8 additions & 0 deletions www/%username/donate.spt
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@ full_title = _("Donate to {0} via Liberapay", participant.username)

<h3>{{ _("Frequently Asked Questions") }}</h3>

% if len(participant.accepted_currencies) == 1
<h4>{{ _("Can I donate in another currency?") }}</h4>
<p>{{ _(
"No, {username} only accepts payments in {currency}.",
username=participant.username, currency=Currency(participant.main_currency)
) }}</p>
% endif

<h4>{{ _("What payment methods are available?") }}</h4>
<p>{{ _(
"We currently support most credit and debit cards (Visa, MasterCard, American Express). "
Expand Down
2 changes: 1 addition & 1 deletion www/%username/tip.spt
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ if tippee.status == 'stub':

if request.method == 'POST':
currency = request.body.get('currency', 'EUR')
if currency not in constants.STANDARD_TIPS:
if currency not in constants.CURRENCIES:
raise response.error(400, "`currency` value in body is invalid or non-supported")
amount = request.body.get('selected_amount')
if amount and amount != 'custom':
Expand Down
2 changes: 1 addition & 1 deletion www/%username/tips.json.spt
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ if request.method == 'POST':
seen.add(tip['username'])
one = {"username": tip['username']}
currency = tip.get('currency', 'EUR')
if currency not in constants.STANDARD_TIPS:
if currency not in constants.CURRENCIES:
raise response.error(400,"`currency` value '%s' in body is invalid or non-supported" % currency)
try:
amount = locale.parse_money_amount(tip['amount'], currency)
Expand Down

0 comments on commit f46179c

Please sign in to comment.