From 3781fc02fafb9144e7901ccabc4998cc70918a95 Mon Sep 17 00:00:00 2001 From: Changaco Date: Mon, 15 Oct 2018 08:37:09 +0200 Subject: [PATCH 01/18] improve handling of currency exponents --- liberapay/billing/transactions.py | 36 ++++++++++------- liberapay/constants.py | 3 -- liberapay/models/_mixin_team.py | 8 ++-- liberapay/models/participant.py | 32 +++++++-------- liberapay/payin/stripe.py | 27 +++++++++++-- liberapay/testing/__init__.py | 10 +++-- liberapay/testing/mangopay.py | 4 +- liberapay/utils/currencies.py | 40 ++++++++++++------- tests/py/test_currencies.py | 30 +++++++++++--- www/%username/charts.json.spt | 3 +- www/%username/giving/index.html.spt | 1 - www/%username/giving/pay/stripe/%payin_id.spt | 4 +- www/about/stats.spt | 4 +- 13 files changed, 130 insertions(+), 72 deletions(-) diff --git a/liberapay/billing/transactions.py b/liberapay/billing/transactions.py index d198852736..543b8cbda1 100644 --- a/liberapay/billing/transactions.py +++ b/liberapay/billing/transactions.py @@ -30,6 +30,12 @@ QUARANTINE = '%s days' % QUARANTINE.days +def Money_to_cents(m): + r = Money(currency=m.currency) + r.amount = int(m.amount * 100) + return r + + def repr_error(o): r = o.ResultCode if r == '000000': @@ -98,9 +104,9 @@ def payout(db, route, amount, ignore_high_fee=False): e_id = record_exchange(db, route, -credit_amount, fee, vat, participant, 'pre').id payout = BankWirePayOut() payout.AuthorId = participant.mangopay_user_id - payout.DebitedFunds = amount.int() + payout.DebitedFunds = Money_to_cents(amount) payout.DebitedWalletId = participant.get_current_wallet(amount.currency).remote_id - payout.Fees = fee.int() + payout.Fees = Money_to_cents(fee) payout.BankAccountId = route.address payout.BankWireRef = str(e_id) payout.Tag = str(e_id) @@ -139,11 +145,11 @@ def charge(db, route, amount, return_url, billing_address=None): if billing_address: payin.Billing = {'Address': billing_address} payin.CreditedWalletId = wallet.remote_id - payin.DebitedFunds = charge_amount.int() + payin.DebitedFunds = Money_to_cents(charge_amount) payin.CardId = route.address payin.SecureMode = 'FORCE' payin.SecureModeReturnURL = return_url - payin.Fees = fee.int() + payin.Fees = Money_to_cents(fee) payin.Tag = str(e_id) try: test_hook() @@ -207,9 +213,9 @@ def execute_direct_debit(db, exchange, route): payin = DirectDebitDirectPayIn() payin.AuthorId = participant.mangopay_user_id payin.CreditedWalletId = exchange.wallet_id - payin.DebitedFunds = debit_amount.int() + payin.DebitedFunds = Money_to_cents(debit_amount) payin.MandateId = route.mandate - payin.Fees = fee.int() + payin.Fees = Money_to_cents(fee) payin.Tag = str(e_id) try: test_hook() @@ -241,8 +247,8 @@ def payin_bank_wire(db, participant, debit_amount): payin = BankWirePayIn() payin.AuthorId = participant.mangopay_user_id payin.CreditedWalletId = wallet.remote_id - payin.DeclaredDebitedFunds = debit_amount.int() - payin.DeclaredFees = fee.int() + payin.DeclaredDebitedFunds = Money_to_cents(debit_amount) + payin.DeclaredFees = Money_to_cents(fee) payin.Tag = str(e_id) try: test_hook() @@ -505,7 +511,7 @@ def transfer(db, tipper, tippee, amount, context, **kw): tr.AuthorId = tipper_wallet.remote_owner_id tr.CreditedUserId = tippee_wallet.remote_owner_id tr.CreditedWalletId = wallet_to - tr.DebitedFunds = amount.int() + tr.DebitedFunds = Money_to_cents(amount) tr.DebitedWalletId = wallet_from tr.Fees = Money(0, amount.currency) tr.Tag = str(t_id) @@ -611,7 +617,7 @@ def initiate_transfer(db, t_id): tr.AuthorId = tipper_wallet.remote_owner_id tr.CreditedUserId = tippee_wallet.remote_owner_id tr.CreditedWalletId = tippee_wallet.remote_id - tr.DebitedFunds = amount.int() + tr.DebitedFunds = Money_to_cents(amount) tr.DebitedWalletId = tipper_wallet.remote_id tr.Fees = Money(0, amount.currency) tr.Tag = str(t_id) @@ -774,8 +780,8 @@ def refund_payin(db, exchange, amount, participant): m_refund = PayInRefund(payin_id=exchange.remote_id) m_refund.AuthorId = wallet.remote_owner_id m_refund.Tag = str(e_refund.id) - m_refund.DebitedFunds = amount.int() - m_refund.Fees = -fee.int() + m_refund.DebitedFunds = Money_to_cents(amount) + m_refund.Fees = -Money_to_cents(fee) try: m_refund.save() except Exception as e: @@ -926,8 +932,8 @@ def refund_disputed_payin(db, exchange, create_debts=False, refund_fee=False, dr m_refund = PayInRefund(payin_id=exchange.remote_id) m_refund.AuthorId = wallet.remote_owner_id m_refund.Tag = str(e_refund.id) - m_refund.DebitedFunds = amount.int() - m_refund.Fees = -fee.int() + m_refund.DebitedFunds = Money_to_cents(amount) + m_refund.Fees = -Money_to_cents(fee) try: m_refund.save() except Exception as e: @@ -1020,7 +1026,7 @@ def recover_lost_funds(db, exchange, lost_amount, repudiation_id): tr.AuthorId = original_owner.mangopay_user_id tr.CreditedUserId = chargebacks_account.mangopay_user_id tr.CreditedWalletId = to_wallet - tr.DebitedFunds = exchange.amount.int() + tr.DebitedFunds = Money_to_cents(exchange.amount) tr.DebitedWalletId = from_wallet tr.Fees = Money(0, currency) tr.RepudiationId = repudiation_id diff --git a/liberapay/constants.py b/liberapay/constants.py index 3d4a588bbb..aca11451a9 100644 --- a/liberapay/constants.py +++ b/liberapay/constants.py @@ -69,7 +69,6 @@ def with_vat(self): D_CENT = Decimal('0.01') D_INF = Decimal('inf') D_MAX = Decimal('999999999999.99') -D_UNIT = Decimal('1.00') D_ZERO = Decimal('0.00') DONATION_LIMITS_WEEKLY_EUR_USD = (Decimal('0.01'), Decimal('100.00')) @@ -386,6 +385,4 @@ def make_standard_tip(label, weekly, currency): USERNAME_MAX_SIZE = 32 USERNAME_SUFFIX_BLACKLIST = set('.txt .html .htm .json .xml'.split()) -ZERO = {c: Money(D_ZERO, c) for c in ('EUR', 'USD', None)} - del _ diff --git a/liberapay/models/_mixin_team.py b/liberapay/models/_mixin_team.py index c8004f9d9a..8d3e1963c3 100644 --- a/liberapay/models/_mixin_team.py +++ b/liberapay/models/_mixin_team.py @@ -7,7 +7,7 @@ from collections import OrderedDict from statistics import median -from liberapay.constants import ZERO, TAKE_THROTTLING_THRESHOLD +from liberapay.constants import TAKE_THROTTLING_THRESHOLD from liberapay.utils import NS, group_by from liberapay.utils.currencies import Money, MoneyBasket @@ -39,7 +39,7 @@ def add_member(self, member, cursor=None): raise MemberLimitReached if member.status != 'active': raise InactiveParticipantAdded - self.set_take_for(member, ZERO[member.main_currency], self, cursor=cursor) + self.set_take_for(member, Money.ZEROS[member.main_currency], self, cursor=cursor) def remove_all_members(self, cursor=None): (cursor or self.db).run(""" @@ -95,7 +95,7 @@ def compute_max_this_week(self, member_id, last_week, currency): leftover, or last week's median take, or one currency unit (e.g. €1.00). """ nonzero_last_week = [a.convert(currency).amount for a in last_week.values() if a] - member_last_week = last_week.get(member_id, ZERO[currency]).convert(currency) + member_last_week = last_week.get(member_id, Money.ZEROS[currency]).convert(currency) return max( member_last_week * 2, member_last_week + last_week.initial_leftover.fuzzy_sum(currency), @@ -289,7 +289,7 @@ def get_members(self): compute_max = self.throttle_takes and nmembers > 1 and last_week.sum members = OrderedDict() members.leftover = self.leftover - zero = ZERO[self.main_currency] + zero = Money.ZEROS[self.main_currency] for take in takes: member = {} m_id = member['id'] = take['member_id'] diff --git a/liberapay/models/participant.py b/liberapay/models/participant.py index 7ba2ac6e64..6a94273445 100644 --- a/liberapay/models/participant.py +++ b/liberapay/models/participant.py @@ -2,6 +2,7 @@ from base64 import b64decode, b64encode from datetime import timedelta +from decimal import Decimal from email.utils import formataddr from hashlib import pbkdf2_hmac, md5, sha1 from os import urandom @@ -16,7 +17,6 @@ from cached_property import cached_property from html2text import html2text import mangopay -from mangopay.utils import Money from markupsafe import escape as htmlescape from pando import json from pando.utils import utcnow @@ -25,12 +25,12 @@ import requests from liberapay.constants import ( - ASCII_ALLOWED_IN_USERNAME, AVATAR_QUERY, CURRENCIES, D_UNIT, D_ZERO, + ASCII_ALLOWED_IN_USERNAME, AVATAR_QUERY, CURRENCIES, DONATION_LIMITS, EMAIL_VERIFICATION_TIMEOUT, EVENTS, HTML_A, PASSWORD_MAX_SIZE, PASSWORD_MIN_SIZE, PAYMENT_SLUGS, PERIOD_CONVERSION_RATES, PRIVILEGES, PROFILE_VISIBILITY_ATTRS, PUBLIC_NAME_MAX_SIZE, SESSION, SESSION_REFRESH, SESSION_TIMEOUT, - USERNAME_MAX_SIZE, USERNAME_SUFFIX_BLACKLIST, ZERO, + USERNAME_MAX_SIZE, USERNAME_SUFFIX_BLACKLIST, ) from liberapay.exceptions import ( BadAmount, @@ -73,7 +73,7 @@ NS, deserialize, erase_cookie, serialize, set_cookie, urlquote, emails, i18n, markdown, ) -from liberapay.utils.currencies import MoneyBasket +from liberapay.utils.currencies import Money, MoneyBasket from liberapay.utils.emails import check_email_blacklist, normalize_email_address from liberapay.website import website @@ -295,7 +295,7 @@ def get_chargebacks_account(cls, currency): ON CONFLICT (remote_id) DO UPDATE SET remote_owner_id = 'CREDIT' -- dummy update RETURNING * - """, ('CREDIT_' + currency, ZERO[currency], p.id)) + """, ('CREDIT_' + currency, Money.ZEROS[currency], p.id)) return p, wallet def refetch(self): @@ -584,7 +584,7 @@ def distribute_balances_to_donees(self, final_gift=True): ] if len(takes) == 1 and len(tip.takes) == 1 and tip.takes[0]['amount'] == 0: # Team of one with a zero take - tip.takes[0]['amount'].amount = D_UNIT + tip.takes[0]['amount'].amount = Decimal('1') tip.total_takes = MoneyBasket(*[t['amount'] for t in tip.takes]) tips = [t for t in tips if getattr(t, 'total_takes', -1) != 0] transfers = [] @@ -598,7 +598,7 @@ def distribute_balances_to_donees(self, final_gift=True): key=lambda t: (t.amount, t.ctime), reverse=True ) total = Money.sum((t.amount for t in tips_in_this_currency), currency) - distributed = ZERO[currency] + distributed = Money.ZEROS[currency] initial_balance = wallet.balance transfers_in_this_currency = [] @@ -1268,7 +1268,7 @@ def get_withdrawable_amount(self, currency): AND disputed IS NOT TRUE AND locked_for IS NULL AND (amount).currency = %s - """, (self.id, QUARANTINE, currency)) or ZERO[currency] + """, (self.id, QUARANTINE, currency)) or Money.ZEROS[currency] def can_withdraw(self, amount): return self.get_withdrawable_amount(amount.currency) >= amount @@ -1302,7 +1302,7 @@ def get_balance_in(self, currency): WHERE owner = %s AND balance::currency = %s AND is_current - """, (self.id, currency)) or ZERO[currency] + """, (self.id, currency)) or Money.ZEROS[currency] def get_balances(self): return self.db.one(""" @@ -1840,7 +1840,7 @@ def get_giving_in(self, currency): AND t.amount::currency = %s AND p.status = 'active' AND (p.goal IS NULL OR p.goal >= 0) - """, (self.id, currency)) or ZERO[currency] + """, (self.id, currency)) or Money.ZEROS[currency] def get_receiving_in(self, currency, cursor=None): r = (cursor or self.db).one(""" @@ -1849,14 +1849,14 @@ def get_receiving_in(self, currency, cursor=None): WHERE t.tippee = %s AND t.amount::currency = %s AND t.is_funded - """, (self.id, currency)) or ZERO[currency] + """, (self.id, currency)) or Money.ZEROS[currency] if currency not in CURRENCIES: raise ValueError(currency) r += Money((cursor or self.db).one(""" SELECT sum((t.actual_amount).{0}) FROM current_takes t WHERE t.member = %s - """.format(currency), (self.id,)) or D_ZERO, currency) + """.format(currency), (self.id,)) or Money.ZEROS[currency].amount, currency) return r def get_exact_receiving(self): @@ -1890,7 +1890,7 @@ def update_giving(self, cursor=None): currencies = set(t.amount.currency for t in tips) balances = {w.balance.currency: w.balance for w in self.get_current_wallets(cursor)} for currency in currencies: - fake_balance = balances.get(currency, ZERO[currency]) + fake_balance = balances.get(currency, Money.ZEROS[currency]) fake_balance += self.get_receiving_in(currency, cursor) for tip in (t for t in tips if t.amount.currency == currency): if tip.amount <= (tip.paid_in_advance or 0): @@ -1939,7 +1939,7 @@ def update_receiving(self, cursor=None): with self.db.get_cursor(cursor) as c: if self.kind == 'group': c.run("LOCK TABLE takes IN EXCLUSIVE MODE") - zero = ZERO[self.main_currency] + zero = Money.ZEROS[self.main_currency] r = c.one(""" WITH our_tips AS ( SELECT amount @@ -2042,7 +2042,7 @@ def _zero_tip_dict(tippee, currency=None): tippee = Participant.from_id(tippee) if not currency or currency not in tippee.accepted_currencies: currency = tippee.main_currency - zero = ZERO[currency] + zero = Money.ZEROS[currency] return dict(amount=zero, is_funded=False, tippee=tippee.id, period='weekly', periodic_amount=zero) @@ -2107,7 +2107,7 @@ def get_tip_distribution(self): tip_amounts = [] npatrons = 0 currency = self.main_currency - contributed = ZERO[currency] + contributed = Money.ZEROS[currency] for rec in recs: tip_amounts.append([ rec.amount, diff --git a/liberapay/payin/stripe.py b/liberapay/payin/stripe.py index 08f1120970..7454cfa6d3 100644 --- a/liberapay/payin/stripe.py +++ b/liberapay/payin/stripe.py @@ -1,5 +1,7 @@ from __future__ import absolute_import, division, print_function, unicode_literals +from decimal import Decimal + import stripe import stripe.error @@ -8,6 +10,25 @@ from .common import update_payin, update_payin_transfer +# https://stripe.com/docs/currencies#presentment-currencies +ZERO_DECIMAL_CURRENCIES = """ + BIF CLP DJF GNF JPY KMF KRW MGA PYG RWF UGX VND VUV XAF XOF XPF +""".split() + + +def int_to_Money(amount, currency): + currency = currency.upper() + if currency in ZERO_DECIMAL_CURRENCIES: + return Money(Decimal(amount), currency) + return Money(Decimal(amount) / 100, currency) + + +def Money_to_int(m): + if m.currency in ZERO_DECIMAL_CURRENCIES: + return int(m.amount) + return int(m.amount * 100) + + def repr_stripe_error(e): """Given a `StripeError` exception, return an error message suitable for display. """ @@ -45,7 +66,7 @@ def destination_charge(db, payin, payer, statement_descriptor): destination = {'account': destination} try: charge = stripe.Charge.create( - amount=amount.int().amount, + amount=Money_to_int(amount), currency=amount.currency.lower(), customer=route.remote_user_id, destination=destination, @@ -63,8 +84,8 @@ def destination_charge(db, payin, payer, statement_descriptor): return update_payin(db, payin.id, '', 'failed', str(e)) bt = charge.balance_transaction - amount_settled = Money(bt.amount, bt.currency.upper()) / 100 - fee = Money(bt.fee, bt.currency.upper()) / 100 + amount_settled = int_to_Money(bt.amount, bt.currency) + fee = int_to_Money(bt.fee, bt.currency) net_amount = amount_settled - fee if destination: diff --git a/liberapay/testing/__init__.py b/liberapay/testing/__init__.py index ba6449ce02..35176aa0b8 100644 --- a/liberapay/testing/__init__.py +++ b/liberapay/testing/__init__.py @@ -8,7 +8,6 @@ from os.path import dirname, join, realpath from aspen import resources -from mangopay.utils import Money from pando.utils import utcnow from pando.testing.client import Client from psycopg2 import IntegrityError, InternalError @@ -17,7 +16,7 @@ from liberapay.billing.transactions import ( record_exchange, record_exchange_result, prepare_transfer, _record_transfer_result ) -from liberapay.constants import SESSION, ZERO +from liberapay.constants import SESSION from liberapay.elsewhere._base import UserInfo from liberapay.main import website from liberapay.models.account_elsewhere import AccountElsewhere @@ -30,6 +29,7 @@ ) from liberapay.security.csrf import CSRF_TOKEN from liberapay.testing.vcr import use_cassette +from liberapay.utils.currencies import Money TOP = realpath(join(dirname(dirname(__file__)), '..')) @@ -41,6 +41,10 @@ def EUR(amount): return Money(amount, 'EUR') +def JPY(amount): + return Money(amount, 'JPY') + + def USD(amount): return Money(amount, 'USD') @@ -217,7 +221,7 @@ def make_participant(self, username, **kw): if is_person and participant.mangopay_user_id: wallet_id = kw2.get('mangopay_wallet_id', -participant.id) - zero = ZERO[participant.main_currency] + zero = Money.ZEROS[participant.main_currency] self.db.run(""" INSERT INTO wallets (remote_id, balance, owner, remote_owner_id) diff --git a/liberapay/testing/mangopay.py b/liberapay/testing/mangopay.py index 14c823fbfa..e0f4e70a7f 100644 --- a/liberapay/testing/mangopay.py +++ b/liberapay/testing/mangopay.py @@ -8,10 +8,10 @@ import mock import requests -from liberapay.constants import ZERO from liberapay.models.exchange_route import ExchangeRoute from liberapay.testing import Harness from liberapay.testing.vcr import use_cassette +from liberapay.utils.currencies import Money class MangopayHarness(Harness): @@ -46,7 +46,7 @@ def fake_transfer(tr): def fake_wallet(w): - w.Balance = ZERO[w.Currency] + w.Balance = Money.ZEROS[w.Currency] w.Id = -next(FakeTransfersHarness.wallet_id_serial) diff --git a/liberapay/utils/currencies.py b/liberapay/utils/currencies.py index 3b2090e1a0..8e97ec4af3 100644 --- a/liberapay/utils/currencies.py +++ b/liberapay/utils/currencies.py @@ -10,11 +10,11 @@ import requests import xmltodict -from liberapay.constants import CURRENCIES, D_CENT, D_ZERO, ZERO +from liberapay.constants import CURRENCIES, D_CENT, D_ZERO from liberapay.website import website -def _convert(self, c): +def _convert(self, c, rounding=ROUND_HALF_UP): if self.currency == c: return self if 'EUR' in (self.currency, c): @@ -25,17 +25,17 @@ def _convert(self, c): website.currency_exchange_rates[('EUR', c)] ) amount = self.amount * rate - return Money(amount.quantize(D_CENT), c) + return Money(amount, c, rounding=rounding) def _sum(cls, amounts, currency): - a = ZERO[currency].amount + a = Money.ZEROS[currency].amount for m in amounts: if m.currency != currency: raise CurrencyMismatch(m.currency, currency, 'sum') a += m.amount return cls(a, currency) -def _Money_init(self, amount=D_ZERO, currency=None): +def _Money_init(self, amount=Decimal('0'), currency=None, rounding=None): if not isinstance(amount, Decimal): amount = Decimal(str(amount)) # Why `str(amount)`? Because: @@ -43,6 +43,9 @@ def _Money_init(self, amount=D_ZERO, currency=None): # Decimal('0.2300000000000000099920072216264088638126850128173828125') # >>> Decimal(str(0.23)) # Decimal('0.23') + if rounding is not None: + minimum = Money.MINIMUMS[currency].amount + amount = amount.quantize(minimum, rounding=rounding) self.amount = amount self.currency = currency @@ -56,16 +59,22 @@ def _Money_eq(self, other): return False def _Money_round(self, rounding=ROUND_HALF_UP): - minimum = Money.minimums[self.currency] - return Money(self.amount.quantize(minimum, rounding=rounding), self.currency) + return Money(self.amount, self.currency, rounding=rounding) class _Minimums(defaultdict): def __missing__(self, currency): exponent = website.db.one("SELECT get_currency_exponent(%s)", (currency,)) - minimum = D_CENT if exponent == 2 else Decimal(10) ** (-exponent) + minimum = Money((D_CENT if exponent == 2 else Decimal(10) ** (-exponent)), currency) self[currency] = minimum return minimum +class _Zeros(defaultdict): + def __missing__(self, currency): + minimum = Money.MINIMUMS[currency].amount + zero = Money((D_ZERO if minimum is D_CENT else minimum - minimum), currency) + self[currency] = zero + return zero + Money.__init__ = _Money_init Money.__nonzero__ = Money.__bool__ @@ -75,21 +84,22 @@ def __missing__(self, currency): Money.__str__ = lambda m: '%(amount)s %(currency)s' % m.__dict__ Money.__unicode__ = Money.__str__ Money.convert = _convert -Money.int = lambda m: Money(int(m.amount * 100), m.currency) -Money.minimum = lambda m: Money.minimums[m.currency] -Money.minimums = _Minimums() +Money.minimum = lambda m: Money.MINIMUMS[m.currency] +Money.MINIMUMS = _Minimums() Money.round = _Money_round Money.round_down = lambda m: m.round(ROUND_DOWN) Money.round_up = lambda m: m.round(ROUND_UP) Money.sum = classmethod(_sum) -Money.zero = lambda m: Money(D_ZERO, m.currency) +Money.zero = lambda m: Money.ZEROS[m.currency] +Money.ZEROS = _Zeros() class MoneyBasket(object): def __init__(self, *args, **decimals): self.amounts = OrderedDict( - (currency, decimals.get(currency, D_ZERO)) for currency in CURRENCIES + (currency, decimals.get(currency, Money.ZEROS[currency].amount)) + for currency in CURRENCIES ) for arg in args: if isinstance(arg, Money): @@ -207,7 +217,7 @@ def currencies_present(self): return [k for k, v in self.amounts.items() if v > 0] def fuzzy_sum(self, currency, rounding=ROUND_UP): - a = ZERO[currency].amount + a = Money.ZEROS[currency].amount fuzzy = False for m in self: if m.currency == currency: @@ -215,7 +225,7 @@ def fuzzy_sum(self, currency, rounding=ROUND_UP): elif m.amount: a += m.amount * website.currency_exchange_rates[(m.currency, currency)] fuzzy = True - r = Money(a.quantize(D_CENT, rounding=rounding), currency) + r = Money(a, currency, rounding=rounding) r.fuzzy = fuzzy return r diff --git a/tests/py/test_currencies.py b/tests/py/test_currencies.py index 13888d1ee6..43a0503960 100644 --- a/tests/py/test_currencies.py +++ b/tests/py/test_currencies.py @@ -6,7 +6,8 @@ from liberapay.billing.transactions import swap_currencies, Transfer from liberapay.exceptions import NegativeBalance, TransferError -from liberapay.testing import EUR, USD, Harness, Foobar +from liberapay.payin.stripe import int_to_Money, Money_to_int +from liberapay.testing import EUR, JPY, USD, Harness, Foobar from liberapay.testing.mangopay import FakeTransfersHarness, MangopayHarness, fake_transfer from liberapay.utils.currencies import Money, MoneyBasket @@ -37,10 +38,10 @@ def test_convert_non_euro(self): assert expected == actual def test_minimums(self): - assert Money.minimums['EUR'] == D('0.01') - assert Money.minimums['USD'] == D('0.01') - assert Money.minimums['KRW'] == D('1') - assert Money.minimums['JPY'] == D('1') + assert Money.MINIMUMS['EUR'].amount == D('0.01') + assert Money.MINIMUMS['USD'].amount == D('0.01') + assert Money.MINIMUMS['KRW'].amount == D('1') + assert Money.MINIMUMS['JPY'].amount == D('1') def test_rounding(self): assert Money('0.001', 'EUR').round() == Money('0.00', 'EUR') @@ -150,3 +151,22 @@ def fail_on_second(tr): 'homer': MoneyBasket(EUR('5.00'), USD('1.00')), 'david': MoneyBasket(), } + + +class TestCurrenciesWithStripe(Harness): + + def test_Money_to_int(self): + expected = 101 + actual = Money_to_int(EUR('1.01')) + assert expected == actual + expected = 1 + actual = Money_to_int(JPY('1')) + assert expected == actual + + def test_int_to_Money(self): + expected = USD('1.02') + actual = int_to_Money(102, 'USD') + assert expected == actual + expected = JPY('1') + actual = int_to_Money(1, 'JPY') + assert expected == actual diff --git a/www/%username/charts.json.spt b/www/%username/charts.json.spt index 0753eb0b43..71e1f729b9 100644 --- a/www/%username/charts.json.spt +++ b/www/%username/charts.json.spt @@ -11,6 +11,7 @@ this to mean, "no chart." """ from liberapay.utils import get_participant +from liberapay.utils.currencies import Money [---] @@ -58,7 +59,7 @@ if not transfers: raise response.json([]) currency = participant.main_currency -zero = constants.ZERO[currency] +zero = Money.ZEROS[currency] paydays_i = iter(paydays) curpayday = next(paydays_i) curpayday['receipts'] = zero diff --git a/www/%username/giving/index.html.spt b/www/%username/giving/index.html.spt index 0fdc5273f5..ad51b1172c 100644 --- a/www/%username/giving/index.html.spt +++ b/www/%username/giving/index.html.spt @@ -3,7 +3,6 @@ from decimal import Decimal from pando.utils import utcnow -from liberapay.constants import ZERO from liberapay.models.participant import Participant from liberapay.utils import get_participant, group_by diff --git a/www/%username/giving/pay/stripe/%payin_id.spt b/www/%username/giving/pay/stripe/%payin_id.spt index 10eb2ee69a..0407702994 100644 --- a/www/%username/giving/pay/stripe/%payin_id.spt +++ b/www/%username/giving/pay/stripe/%payin_id.spt @@ -9,7 +9,7 @@ import stripe.error from liberapay.models.exchange_route import ExchangeRoute from liberapay.models.participant import Participant from liberapay.payin.common import prepare_payin, prepare_payin_transfer, resolve_destination -from liberapay.payin.stripe import destination_charge, repr_stripe_error +from liberapay.payin.stripe import destination_charge, Money_to_int, repr_stripe_error from liberapay.utils import get_participant, NS, partition ONE_YEAR = { @@ -87,7 +87,7 @@ if request.method == 'POST': raise response.error(400, "unknown token type") source = stripe.Source.create( - amount=amount.int().amount if one_off else None, + amount=Money_to_int(amount) if one_off else None, owner={ 'address': owner_address, 'email': payer.email or payer.get_any_email(), diff --git a/www/about/stats.spt b/www/about/stats.spt index 8fd6494835..644b0d837c 100644 --- a/www/about/stats.spt +++ b/www/about/stats.spt @@ -1,7 +1,7 @@ # coding: utf8 from __future__ import division, print_function, unicode_literals -from liberapay.utils.currencies import MoneyBasket +from liberapay.utils.currencies import Money, MoneyBasket db = website.db db_qc5 = website.db_qc5 @@ -9,7 +9,7 @@ db_qc5 = website.db_qc5 [--------------------------------------------------------] title = _("Stats") -zero = constants.ZERO[currency] +zero = Money.ZEROS[currency] escrow = db_qc5.one("SELECT basket_sum(balance) FROM wallets") or MoneyBasket() nusers = db_qc5.one(""" SELECT count(*) From f46179c519f218d1e3ada5031e7af29c7e91b7cc Mon Sep 17 00:00:00 2001 From: Changaco Date: Sun, 14 Oct 2018 14:57:33 +0200 Subject: [PATCH 02/18] add support for 31 other currencies --- liberapay/constants.py | 101 ++++++++++++++++++++++++++-------- liberapay/utils/currencies.py | 2 +- templates/currencies.html | 18 +++--- templates/your-tip.html | 56 ++++++++++--------- tests/py/test_history.py | 4 +- www/%username/donate.spt | 8 +++ www/%username/tip.spt | 2 +- www/%username/tips.json.spt | 2 +- 8 files changed, 129 insertions(+), 64 deletions(-) diff --git a/liberapay/constants.py b/liberapay/constants.py index aca11451a9..c4474e77e8 100644 --- a/liberapay/constants.py +++ b/liberapay/constants.py @@ -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 @@ -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') @@ -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, @@ -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''' ^ @@ -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"), @@ -366,6 +410,17 @@ 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')), @@ -373,10 +428,10 @@ def make_standard_tip(label, weekly, currency): (_("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 diff --git a/liberapay/utils/currencies.py b/liberapay/utils/currencies.py index 8e97ec4af3..8e405b581b 100644 --- a/liberapay/utils/currencies.py +++ b/liberapay/utils/currencies.py @@ -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): diff --git a/templates/currencies.html b/templates/currencies.html index 9f7ca9d1d8..6fb6a2d96a 100644 --- a/templates/currencies.html +++ b/templates/currencies.html @@ -1,14 +1,10 @@ % macro currency_buttons(name, selected) -
- % for c in constants.CURRENCIES - + % endmacro diff --git a/templates/your-tip.html b/templates/your-tip.html index 82f3141e2e..076ca5806c 100644 --- a/templates/your-tip.html +++ b/templates/your-tip.html @@ -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
% if currency_mismatch - % set tippee_name = tippee.friendly_name if pledging else tippee.username

{{ _( "You are currently donating {money_amount} per week to {name}, " "but they no longer accept donations in {currency}. You can " @@ -32,7 +35,6 @@

% else % if tip.amount > 0 - % set tippee_name = tippee.friendly_name if pledging else tippee.username % if currency_mismatch

{{ _( "You are currently donating {money_amount} per week to {name}, but " @@ -53,30 +55,34 @@ % else

{{ _("Please select or input an amount:") }}

% endif -
- % for c in constants.CURRENCIES - % set is_accepted = c in accepted_currencies - - - % if is_accepted -
- - - -
- {{ tip_select(tip, c, tippee, disabled, pledging=pledging) }} -
+ + + +
+ {{ tip_select(tip, new_currency, tippee, disabled, pledging=pledging) }} +
+
+ % if len(accepted_currencies) > 1 +
+

{{ ngettext( + "Wrong currency? {username} also accepts {n} other:", + "Wrong currency? {username} also accepts {n} others:", + n=len(accepted_currencies) - 1, username=tippee_name + ) }}

+
+ +
- % endif - % endfor -
+ % endif % endif % endmacro diff --git a/tests/py/test_history.py b/tests/py/test_history.py index b952e9cba0..1d024bf2f6 100644 --- a/tests/py/test_history.py +++ b/tests/py/test_history.py @@ -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 @@ -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): diff --git a/www/%username/donate.spt b/www/%username/donate.spt index 0c4a0ea602..c938092a7e 100644 --- a/www/%username/donate.spt +++ b/www/%username/donate.spt @@ -77,6 +77,14 @@ full_title = _("Donate to {0} via Liberapay", participant.username)

{{ _("Frequently Asked Questions") }}

+ % if len(participant.accepted_currencies) == 1 +

{{ _("Can I donate in another currency?") }}

+

{{ _( + "No, {username} only accepts payments in {currency}.", + username=participant.username, currency=Currency(participant.main_currency) + ) }}

+ % endif +

{{ _("What payment methods are available?") }}

{{ _( "We currently support most credit and debit cards (Visa, MasterCard, American Express). " diff --git a/www/%username/tip.spt b/www/%username/tip.spt index eb71951420..7c91191bde 100644 --- a/www/%username/tip.spt +++ b/www/%username/tip.spt @@ -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': diff --git a/www/%username/tips.json.spt b/www/%username/tips.json.spt index 3c071b3e70..7d6b27b9d9 100644 --- a/www/%username/tips.json.spt +++ b/www/%username/tips.json.spt @@ -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) From 31720259684750bb3e446c92207feee22305c8b7 Mon Sep 17 00:00:00 2001 From: Changaco Date: Sun, 14 Oct 2018 14:58:05 +0200 Subject: [PATCH 03/18] expand information about accepting foreign currencies closes #873 --- www/%username/edit/currencies.spt | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/www/%username/edit/currencies.spt b/www/%username/edit/currencies.spt index c20296c6e8..bbece0098a 100644 --- a/www/%username/edit/currencies.spt +++ b/www/%username/edit/currencies.spt @@ -46,6 +46,8 @@ title = participant.username subhead = _("Currencies") [---] text/html +% from "templates/icons.html" import glyphicon + % extends "templates/profile-edit.html" % block form @@ -75,9 +77,27 @@ subhead = _("Currencies") % endfor -

{{ _("Accepting foreign currencies can complicate things for you, but rejecting them can discourage some donors.") }}

+

{{ glyphicon('info-sign') }} {{ _( + "Accepting foreign currencies can increase your income by convincing " + "people in other countries to donate to you, but international payments " + "generate extra fees, and fluctuations in exchange rates can lessen the " + "stability of your income." + ) }}

+ +

{{ glyphicon('info-sign') }} {{ _( + "Stripe automatically converts funds into your main currency, but by " + "default PayPal holds payments in foreign currencies until you tell it " + "what to do. If you have a Business PayPal account you can choose to " + "automatically convert all incoming payments in foreign currencies to " + "your main currency. This option is currently located in the " + "“{link_open}Preferences for receiving payments{link_close}” page.", + link_open=''|safe, + link_close=''|safe + ) }}

- +
+ % endblock From 63a94b33e1a101d4b3cbe715f320feef3f8601ea Mon Sep 17 00:00:00 2001 From: Changaco Date: Sun, 14 Oct 2018 18:01:46 +0200 Subject: [PATCH 04/18] allow accepting all currencies (including future ones) --- www/%username/edit/currencies.spt | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/www/%username/edit/currencies.spt b/www/%username/edit/currencies.spt index bbece0098a..66e5ca4e16 100644 --- a/www/%username/edit/currencies.spt +++ b/www/%username/edit/currencies.spt @@ -10,11 +10,14 @@ if request.method == 'POST': new_currency = body['main_currency'] old_currency = participant.main_currency change_currency = new_currency != old_currency - new_accepted = [c for c in constants.CURRENCIES if body.get('accepted_currencies:' + c) == 'yes'] + if body.get('accepted_currencies') == '*': + new_accepted = None + else: + new_accepted = [c for c in constants.CURRENCIES if body.get('accepted_currencies:' + c) == 'yes'] + if new_currency not in new_accepted: + raise response.error(400, _("The submitted settings are incoherent.")) change_policy = new_accepted != participant.accepted_currencies - if new_currency not in new_accepted: - raise response.error(400, _("The submitted settings are incoherent.")) - if change_currency or change_policy: + if new_accepted is not None and (change_currency or change_policy): foreign_donations = website.db.all(""" SELECT t.* FROM current_tips t @@ -38,10 +41,12 @@ if request.method == 'POST': UPDATE participants SET accepted_currencies = %s WHERE id = %s - """, (','.join(new_accepted), participant.id)) + """, (','.join(new_accepted) if new_accepted else None, participant.id)) participant.set_attributes(accepted_currencies=new_accepted) form_post_success(state) +accept_all = participant._accepted_currencies is None + title = participant.username subhead = _("Currencies") @@ -57,6 +62,12 @@ subhead = _("Currencies")

{{ _("Which currencies should your donors be allowed to send you, and which one do you prefer?") }}

+ + % for c in constants.CURRENCIES
@@ -65,7 +76,7 @@ subhead = _("Currencies")